aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Api
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Api')
-rw-r--r--Jellyfin.Api/Auth/BaseAuthorizationHandler.cs102
-rw-r--r--Jellyfin.Api/Auth/CustomAuthenticationHandler.cs26
-rw-r--r--Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs42
-rw-r--r--Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs11
-rw-r--r--Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs22
-rw-r--r--Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs42
-rw-r--r--Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs11
-rw-r--r--Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs44
-rw-r--r--Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs11
-rw-r--r--Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs26
-rw-r--r--Jellyfin.Api/Constants/InternalClaimTypes.cs38
-rw-r--r--Jellyfin.Api/Constants/Policies.cs15
-rw-r--r--Jellyfin.Api/Controllers/ActivityLogController.cs1
-rw-r--r--Jellyfin.Api/Controllers/ConfigurationController.cs22
-rw-r--r--Jellyfin.Api/Controllers/DevicesController.cs20
-rw-r--r--Jellyfin.Api/Controllers/FilterController.cs220
-rw-r--r--Jellyfin.Api/Controllers/ImageByNameController.cs229
-rw-r--r--Jellyfin.Api/Controllers/ItemRefreshController.cs88
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs341
-rw-r--r--Jellyfin.Api/Controllers/LocalizationController.cs76
-rw-r--r--Jellyfin.Api/Controllers/NotificationsController.cs31
-rw-r--r--Jellyfin.Api/Controllers/PackageController.cs19
-rw-r--r--Jellyfin.Api/Controllers/PluginsController.cs188
-rw-r--r--Jellyfin.Api/Controllers/RemoteImageController.cs265
-rw-r--r--Jellyfin.Api/Controllers/SearchController.cs3
-rw-r--r--Jellyfin.Api/Controllers/StartupController.cs44
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs348
-rw-r--r--Jellyfin.Api/Controllers/VideoAttachmentsController.cs5
-rw-r--r--Jellyfin.Api/Helpers/ClaimHelpers.cs77
-rw-r--r--Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs2
-rw-r--r--Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs2
-rw-r--r--Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs2
-rw-r--r--Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs2
-rw-r--r--Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs40
-rw-r--r--Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs18
-rw-r--r--Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs8
-rw-r--r--Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs6
37 files changed, 2342 insertions, 105 deletions
diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
new file mode 100644
index 000000000..b5b9d8904
--- /dev/null
+++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs
@@ -0,0 +1,102 @@
+#nullable enable
+
+using System.Net;
+using System.Security.Claims;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth
+{
+ /// <summary>
+ /// Base authorization handler.
+ /// </summary>
+ /// <typeparam name="T">Type of Authorization Requirement.</typeparam>
+ public abstract class BaseAuthorizationHandler<T> : AuthorizationHandler<T>
+ where T : IAuthorizationRequirement
+ {
+ private readonly IUserManager _userManager;
+ private readonly INetworkManager _networkManager;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BaseAuthorizationHandler{T}"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+ /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+ protected BaseAuthorizationHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ {
+ _userManager = userManager;
+ _networkManager = networkManager;
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ /// <summary>
+ /// Validate authenticated claims.
+ /// </summary>
+ /// <param name="claimsPrincipal">Request claims.</param>
+ /// <param name="ignoreSchedule">Whether to ignore parental control.</param>
+ /// <param name="localAccessOnly">Whether access is to be allowed locally only.</param>
+ /// <returns>Validated claim status.</returns>
+ protected bool ValidateClaims(
+ ClaimsPrincipal claimsPrincipal,
+ bool ignoreSchedule = false,
+ bool localAccessOnly = false)
+ {
+ // Ensure claim has userId.
+ var userId = ClaimHelpers.GetUserId(claimsPrincipal);
+ if (userId == null)
+ {
+ return false;
+ }
+
+ // Ensure userId links to a valid user.
+ var user = _userManager.GetUserById(userId.Value);
+ if (user == null)
+ {
+ return false;
+ }
+
+ // Ensure user is not disabled.
+ if (user.HasPermission(PermissionKind.IsDisabled))
+ {
+ return false;
+ }
+
+ var ip = NormalizeIp(_httpContextAccessor.HttpContext.Connection.RemoteIpAddress).ToString();
+ var isInLocalNetwork = _networkManager.IsInLocalNetwork(ip);
+ // User cannot access remotely and user is remote
+ if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !isInLocalNetwork)
+ {
+ return false;
+ }
+
+ if (localAccessOnly && !isInLocalNetwork)
+ {
+ return false;
+ }
+
+ // User attempting to access out of parental control hours.
+ if (!ignoreSchedule
+ && !user.HasPermission(PermissionKind.IsAdministrator)
+ && !user.IsParentalScheduleAllowed())
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static IPAddress NormalizeIp(IPAddress ip)
+ {
+ return ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4() : ip;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
index 100054096..5e5e25e84 100644
--- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
+++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
@@ -1,8 +1,12 @@
+#nullable enable
+
+using System.Globalization;
using System.Security.Authentication;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
+using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
@@ -38,15 +42,10 @@ namespace Jellyfin.Api.Auth
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
- var authenticatedAttribute = new AuthenticatedAttribute
- {
- IgnoreLegacyAuth = true
- };
-
try
{
- var user = _authService.Authenticate(Request, authenticatedAttribute);
- if (user == null)
+ var authorizationInfo = _authService.Authenticate(Request);
+ if (authorizationInfo == null)
{
return Task.FromResult(AuthenticateResult.NoResult());
// TODO return when legacy API is removed.
@@ -56,11 +55,16 @@ namespace Jellyfin.Api.Auth
var claims = new[]
{
- new Claim(ClaimTypes.Name, user.Name),
- new Claim(
- ClaimTypes.Role,
- value: user.Policy.IsAdministrator ? UserRoles.Administrator : UserRoles.User)
+ new Claim(ClaimTypes.Name, authorizationInfo.User.Username),
+ new Claim(ClaimTypes.Role, value: authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User),
+ new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
+ new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId),
+ new Claim(InternalClaimTypes.Device, authorizationInfo.Device),
+ new Claim(InternalClaimTypes.Client, authorizationInfo.Client),
+ new Claim(InternalClaimTypes.Version, authorizationInfo.Version),
+ new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
};
+
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
new file mode 100644
index 000000000..b5913daab
--- /dev/null
+++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs
@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
+{
+ /// <summary>
+ /// Default authorization handler.
+ /// </summary>
+ public class DefaultAuthorizationHandler : BaseAuthorizationHandler<DefaultAuthorizationRequirement>
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DefaultAuthorizationHandler"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+ /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+ public DefaultAuthorizationHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
+ {
+ }
+
+ /// <inheritdoc />
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement)
+ {
+ var validated = ValidateClaims(context.User);
+ if (!validated)
+ {
+ context.Fail();
+ return Task.CompletedTask;
+ }
+
+ context.Succeed(requirement);
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs
new file mode 100644
index 000000000..7cea00b69
--- /dev/null
+++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
+{
+ /// <summary>
+ /// The default authorization requirement.
+ /// </summary>
+ public class DefaultAuthorizationRequirement : IAuthorizationRequirement
+ {
+ }
+}
diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs
index 34aa5d12c..decbe0c03 100644
--- a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs
+++ b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs
@@ -1,22 +1,33 @@
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
{
/// <summary>
/// Authorization handler for requiring first time setup or elevated privileges.
/// </summary>
- public class FirstTimeSetupOrElevatedHandler : AuthorizationHandler<FirstTimeSetupOrElevatedRequirement>
+ public class FirstTimeSetupOrElevatedHandler : BaseAuthorizationHandler<FirstTimeSetupOrElevatedRequirement>
{
private readonly IConfigurationManager _configurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="FirstTimeSetupOrElevatedHandler" /> class.
/// </summary>
- /// <param name="configurationManager">The jellyfin configuration manager.</param>
- public FirstTimeSetupOrElevatedHandler(IConfigurationManager configurationManager)
+ /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+ /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+ public FirstTimeSetupOrElevatedHandler(
+ IConfigurationManager configurationManager,
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
{
_configurationManager = configurationManager;
}
@@ -27,8 +38,11 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy
if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
{
context.Succeed(firstTimeSetupOrElevatedRequirement);
+ return Task.CompletedTask;
}
- else if (context.User.IsInRole(UserRoles.Administrator))
+
+ var validated = ValidateClaims(context.User);
+ if (validated && context.User.IsInRole(UserRoles.Administrator))
{
context.Succeed(firstTimeSetupOrElevatedRequirement);
}
diff --git a/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs
new file mode 100644
index 000000000..9afa0b28f
--- /dev/null
+++ b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs
@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.IgnoreSchedulePolicy
+{
+ /// <summary>
+ /// Escape schedule controls handler.
+ /// </summary>
+ public class IgnoreScheduleHandler : BaseAuthorizationHandler<IgnoreScheduleRequirement>
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IgnoreScheduleHandler"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+ /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+ public IgnoreScheduleHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
+ {
+ }
+
+ /// <inheritdoc />
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreScheduleRequirement requirement)
+ {
+ var validated = ValidateClaims(context.User, ignoreSchedule: true);
+ if (!validated)
+ {
+ context.Fail();
+ return Task.CompletedTask;
+ }
+
+ context.Succeed(requirement);
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs
new file mode 100644
index 000000000..d5bb61ce6
--- /dev/null
+++ b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.IgnoreSchedulePolicy
+{
+ /// <summary>
+ /// Escape schedule controls requirement.
+ /// </summary>
+ public class IgnoreScheduleRequirement : IAuthorizationRequirement
+ {
+ }
+}
diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
new file mode 100644
index 000000000..af73352bc
--- /dev/null
+++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs
@@ -0,0 +1,44 @@
+using System.Threading.Tasks;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.LocalAccessPolicy
+{
+ /// <summary>
+ /// Local access handler.
+ /// </summary>
+ public class LocalAccessHandler : BaseAuthorizationHandler<LocalAccessRequirement>
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LocalAccessHandler"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+ /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+ public LocalAccessHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
+ {
+ }
+
+ /// <inheritdoc />
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement)
+ {
+ var validated = ValidateClaims(context.User, localAccessOnly: true);
+ if (!validated)
+ {
+ context.Fail();
+ }
+ else
+ {
+ context.Succeed(requirement);
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs
new file mode 100644
index 000000000..761127fa4
--- /dev/null
+++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.LocalAccessPolicy
+{
+ /// <summary>
+ /// The local access authorization requirement.
+ /// </summary>
+ public class LocalAccessRequirement : IAuthorizationRequirement
+ {
+ }
+}
diff --git a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
index 2d3bb1aa4..b235c4b63 100644
--- a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
+++ b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs
@@ -1,21 +1,43 @@
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
namespace Jellyfin.Api.Auth.RequiresElevationPolicy
{
/// <summary>
/// Authorization handler for requiring elevated privileges.
/// </summary>
- public class RequiresElevationHandler : AuthorizationHandler<RequiresElevationRequirement>
+ public class RequiresElevationHandler : BaseAuthorizationHandler<RequiresElevationRequirement>
{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RequiresElevationHandler"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+ /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+ public RequiresElevationHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
+ {
+ }
+
/// <inheritdoc />
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement)
{
- if (context.User.IsInRole(UserRoles.Administrator))
+ var validated = ValidateClaims(context.User);
+ if (validated && context.User.IsInRole(UserRoles.Administrator))
{
context.Succeed(requirement);
}
+ else
+ {
+ context.Fail();
+ }
return Task.CompletedTask;
}
diff --git a/Jellyfin.Api/Constants/InternalClaimTypes.cs b/Jellyfin.Api/Constants/InternalClaimTypes.cs
new file mode 100644
index 000000000..4d7c7135d
--- /dev/null
+++ b/Jellyfin.Api/Constants/InternalClaimTypes.cs
@@ -0,0 +1,38 @@
+namespace Jellyfin.Api.Constants
+{
+ /// <summary>
+ /// Internal claim types for authorization.
+ /// </summary>
+ public static class InternalClaimTypes
+ {
+ /// <summary>
+ /// User Id.
+ /// </summary>
+ public const string UserId = "Jellyfin-UserId";
+
+ /// <summary>
+ /// Device Id.
+ /// </summary>
+ public const string DeviceId = "Jellyfin-DeviceId";
+
+ /// <summary>
+ /// Device.
+ /// </summary>
+ public const string Device = "Jellyfin-Device";
+
+ /// <summary>
+ /// Client.
+ /// </summary>
+ public const string Client = "Jellyfin-Client";
+
+ /// <summary>
+ /// Version.
+ /// </summary>
+ public const string Version = "Jellyfin-Version";
+
+ /// <summary>
+ /// Token.
+ /// </summary>
+ public const string Token = "Jellyfin-Token";
+ }
+}
diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs
index e2b383f75..cf574e43d 100644
--- a/Jellyfin.Api/Constants/Policies.cs
+++ b/Jellyfin.Api/Constants/Policies.cs
@@ -6,6 +6,11 @@ namespace Jellyfin.Api.Constants
public static class Policies
{
/// <summary>
+ /// Policy name for default authorization.
+ /// </summary>
+ public const string DefaultAuthorization = "DefaultAuthorization";
+
+ /// <summary>
/// Policy name for requiring first time setup or elevated privileges.
/// </summary>
public const string FirstTimeSetupOrElevated = "FirstTimeOrElevated";
@@ -14,5 +19,15 @@ namespace Jellyfin.Api.Constants
/// Policy name for requiring elevated privileges.
/// </summary>
public const string RequiresElevation = "RequiresElevation";
+
+ /// <summary>
+ /// Policy name for allowing local access only.
+ /// </summary>
+ public const string LocalAccessOnly = "LocalAccessOnly";
+
+ /// <summary>
+ /// Policy name for escaping schedule controls.
+ /// </summary>
+ public const string IgnoreSchedule = "IgnoreSchedule";
}
}
diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs
index 895d9f719..4ae7cf506 100644
--- a/Jellyfin.Api/Controllers/ActivityLogController.cs
+++ b/Jellyfin.Api/Controllers/ActivityLogController.cs
@@ -1,4 +1,3 @@
-#nullable enable
#pragma warning disable CA1801
using System;
diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs
index 2a1dce74d..74f1677bd 100644
--- a/Jellyfin.Api/Controllers/ConfigurationController.cs
+++ b/Jellyfin.Api/Controllers/ConfigurationController.cs
@@ -1,5 +1,3 @@
-#nullable enable
-
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
@@ -18,7 +16,7 @@ namespace Jellyfin.Api.Controllers
/// Configuration Controller.
/// </summary>
[Route("System")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class ConfigurationController : BaseJellyfinApiController
{
private readonly IServerConfigurationManager _configurationManager;
@@ -53,15 +51,15 @@ namespace Jellyfin.Api.Controllers
/// Updates application configuration.
/// </summary>
/// <param name="configuration">Configuration.</param>
- /// <response code="200">Configuration updated.</response>
+ /// <response code="204">Configuration updated.</response>
/// <returns>Update status.</returns>
[HttpPost("Configuration")]
[Authorize(Policy = Policies.RequiresElevation)]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateConfiguration([FromBody, BindRequired] ServerConfiguration configuration)
{
_configurationManager.ReplaceConfiguration(configuration);
- return Ok();
+ return NoContent();
}
/// <summary>
@@ -81,17 +79,17 @@ namespace Jellyfin.Api.Controllers
/// Updates named configuration.
/// </summary>
/// <param name="key">Configuration key.</param>
- /// <response code="200">Named configuration updated.</response>
+ /// <response code="204">Named configuration updated.</response>
/// <returns>Update status.</returns>
[HttpPost("Configuration/{Key}")]
[Authorize(Policy = Policies.RequiresElevation)]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string key)
{
var configurationType = _configurationManager.GetConfigurationType(key);
var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType).ConfigureAwait(false);
_configurationManager.SaveConfiguration(key, configuration);
- return Ok();
+ return NoContent();
}
/// <summary>
@@ -111,15 +109,15 @@ namespace Jellyfin.Api.Controllers
/// Updates the path to the media encoder.
/// </summary>
/// <param name="mediaEncoderPath">Media encoder path form body.</param>
- /// <response code="200">Media encoder path updated.</response>
+ /// <response code="204">Media encoder path updated.</response>
/// <returns>Status.</returns>
[HttpPost("MediaEncoder/Path")]
[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateMediaEncoderPath([FromForm, BindRequired] MediaEncoderPathDto mediaEncoderPath)
{
_mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
- return Ok();
+ return NoContent();
}
}
}
diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs
index 1e7557903..78368eed6 100644
--- a/Jellyfin.Api/Controllers/DevicesController.cs
+++ b/Jellyfin.Api/Controllers/DevicesController.cs
@@ -1,5 +1,3 @@
-#nullable enable
-
using System;
using Jellyfin.Api.Constants;
using MediaBrowser.Controller.Devices;
@@ -17,7 +15,7 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Devices Controller.
/// </summary>
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class DevicesController : BaseJellyfinApiController
{
private readonly IDeviceManager _deviceManager;
@@ -105,12 +103,12 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="id">Device Id.</param>
/// <param name="deviceOptions">Device Options.</param>
- /// <response code="200">Device options updated.</response>
+ /// <response code="204">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>
+ /// <returns>A <see cref="NoContentResult"/> 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.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateDeviceOptions(
[FromQuery, BindRequired] string id,
@@ -123,18 +121,18 @@ namespace Jellyfin.Api.Controllers
}
_deviceManager.UpdateDeviceOptions(id, deviceOptions);
- return Ok();
+ return NoContent();
}
/// <summary>
/// Deletes a device.
/// </summary>
/// <param name="id">Device Id.</param>
- /// <response code="200">Device deleted.</response>
+ /// <response code="204">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>
+ /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
[HttpDelete]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DeleteDevice([FromQuery, BindRequired] string id)
{
var existingDevice = _deviceManager.GetDevice(id);
@@ -150,7 +148,7 @@ namespace Jellyfin.Api.Controllers
_sessionManager.Logout(session);
}
- return Ok();
+ return NoContent();
}
}
}
diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs
new file mode 100644
index 000000000..6a6e6a64a
--- /dev/null
+++ b/Jellyfin.Api/Controllers/FilterController.cs
@@ -0,0 +1,220 @@
+#pragma warning disable CA1801
+
+using System;
+using System.Linq;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Filters controller.
+ /// </summary>
+ [Authorize]
+ public class FilterController : BaseJellyfinApiController
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="FilterController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ public FilterController(ILibraryManager libraryManager, IUserManager userManager)
+ {
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ }
+
+ /// <summary>
+ /// Gets legacy query filters.
+ /// </summary>
+ /// <param name="userId">Optional. User id.</param>
+ /// <param name="parentId">Optional. Parent id.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+ /// <response code="200">Legacy filters retrieved.</response>
+ /// <returns>Legacy query filters.</returns>
+ [HttpGet("/Items/Filters")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
+ [FromQuery] Guid? userId,
+ [FromQuery] string? parentId,
+ [FromQuery] string? includeItemTypes,
+ [FromQuery] string? mediaTypes)
+ {
+ var parentItem = string.IsNullOrEmpty(parentId)
+ ? null
+ : _libraryManager.GetItemById(parentId);
+
+ var user = userId == null || userId == Guid.Empty
+ ? null
+ : _userManager.GetUserById(userId.Value);
+
+ if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+ {
+ parentItem = null;
+ }
+
+ var item = string.IsNullOrEmpty(parentId)
+ ? user == null
+ ? _libraryManager.RootFolder
+ : _libraryManager.GetUserRootFolder()
+ : parentItem;
+
+ var query = new InternalItemsQuery
+ {
+ User = user,
+ MediaTypes = (mediaTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+ IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+ Recursive = true,
+ EnableTotalRecordCount = false,
+ DtoOptions = new DtoOptions
+ {
+ Fields = new[] { ItemFields.Genres, ItemFields.Tags },
+ EnableImages = false,
+ EnableUserData = false
+ }
+ };
+
+ var itemList = ((Folder)item!).GetItemList(query);
+ return new QueryFiltersLegacy
+ {
+ Years = itemList.Select(i => i.ProductionYear ?? -1)
+ .Where(i => i > 0)
+ .Distinct()
+ .OrderBy(i => i)
+ .ToArray(),
+
+ Genres = itemList.SelectMany(i => i.Genres)
+ .DistinctNames()
+ .OrderBy(i => i)
+ .ToArray(),
+
+ Tags = itemList
+ .SelectMany(i => i.Tags)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(i => i)
+ .ToArray(),
+
+ OfficialRatings = itemList
+ .Select(i => i.OfficialRating)
+ .Where(i => !string.IsNullOrWhiteSpace(i))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(i => i)
+ .ToArray()
+ };
+ }
+
+ /// <summary>
+ /// Gets query filters.
+ /// </summary>
+ /// <param name="userId">Optional. User id.</param>
+ /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="mediaTypes">[Unused] Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+ /// <param name="isAiring">Optional. Is item airing.</param>
+ /// <param name="isMovie">Optional. Is item movie.</param>
+ /// <param name="isSports">Optional. Is item sports.</param>
+ /// <param name="isKids">Optional. Is item kids.</param>
+ /// <param name="isNews">Optional. Is item news.</param>
+ /// <param name="isSeries">Optional. Is item series.</param>
+ /// <param name="recursive">Optional. Search recursive.</param>
+ /// <response code="200">Filters retrieved.</response>
+ /// <returns>Query filters.</returns>
+ [HttpGet("/Items/Filters2")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryFilters> GetQueryFilters(
+ [FromQuery] Guid? userId,
+ [FromQuery] string? parentId,
+ [FromQuery] string? includeItemTypes,
+ [FromQuery] string? mediaTypes,
+ [FromQuery] bool? isAiring,
+ [FromQuery] bool? isMovie,
+ [FromQuery] bool? isSports,
+ [FromQuery] bool? isKids,
+ [FromQuery] bool? isNews,
+ [FromQuery] bool? isSeries,
+ [FromQuery] bool? recursive)
+ {
+ var parentItem = string.IsNullOrEmpty(parentId)
+ ? null
+ : _libraryManager.GetItemById(parentId);
+
+ var user = userId == null || userId == Guid.Empty
+ ? null
+ : _userManager.GetUserById(userId.Value);
+
+ if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+ {
+ parentItem = null;
+ }
+
+ var filters = new QueryFilters();
+ var genreQuery = new InternalItemsQuery(user)
+ {
+ IncludeItemTypes =
+ (includeItemTypes ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries),
+ DtoOptions = new DtoOptions
+ {
+ Fields = Array.Empty<ItemFields>(),
+ EnableImages = false,
+ EnableUserData = false
+ },
+ IsAiring = isAiring,
+ IsMovie = isMovie,
+ IsSports = isSports,
+ IsKids = isKids,
+ IsNews = isNews,
+ IsSeries = isSeries
+ };
+
+ if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
+ {
+ genreQuery.AncestorIds = parentItem == null ? Array.Empty<Guid>() : new[] { parentItem.Id };
+ }
+ else
+ {
+ genreQuery.Parent = parentItem;
+ }
+
+ if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase))
+ {
+ filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
+ {
+ Name = i.Item1.Name,
+ Id = i.Item1.Id
+ }).ToArray();
+ }
+ else
+ {
+ filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair
+ {
+ Name = i.Item1.Name,
+ Id = i.Item1.Id
+ }).ToArray();
+ }
+
+ return filters;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/ImageByNameController.cs b/Jellyfin.Api/Controllers/ImageByNameController.cs
new file mode 100644
index 000000000..70f46ffa4
--- /dev/null
+++ b/Jellyfin.Api/Controllers/ImageByNameController.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Images By Name Controller.
+ /// </summary>
+ [Route("Images")]
+ public class ImageByNameController : BaseJellyfinApiController
+ {
+ private readonly IServerApplicationPaths _applicationPaths;
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ImageByNameController" /> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager" /> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem" /> interface.</param>
+ public ImageByNameController(
+ IServerConfigurationManager serverConfigurationManager,
+ IFileSystem fileSystem)
+ {
+ _applicationPaths = serverConfigurationManager.ApplicationPaths;
+ _fileSystem = fileSystem;
+ }
+
+ /// <summary>
+ /// Get all general images.
+ /// </summary>
+ /// <response code="200">Retrieved list of images.</response>
+ /// <returns>An <see cref="OkResult"/> containing the list of images.</returns>
+ [HttpGet("General")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<ImageByNameInfo>> GetGeneralImages()
+ {
+ return GetImageList(_applicationPaths.GeneralPath, false);
+ }
+
+ /// <summary>
+ /// Get General Image.
+ /// </summary>
+ /// <param name="name">The name of the image.</param>
+ /// <param name="type">Image Type (primary, backdrop, logo, etc).</param>
+ /// <response code="200">Image stream retrieved.</response>
+ /// <response code="404">Image not found.</response>
+ /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+ [HttpGet("General/{Name}/{Type}")]
+ [AllowAnonymous]
+ [Produces(MediaTypeNames.Application.Octet)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<FileStreamResult> GetGeneralImage([FromRoute] string name, [FromRoute] string type)
+ {
+ var filename = string.Equals(type, "primary", StringComparison.OrdinalIgnoreCase)
+ ? "folder"
+ : type;
+
+ var path = BaseItem.SupportedImageExtensions
+ .Select(i => Path.Combine(_applicationPaths.GeneralPath, name, filename + i))
+ .FirstOrDefault(System.IO.File.Exists);
+
+ if (path == null)
+ {
+ return NotFound();
+ }
+
+ var contentType = MimeTypes.GetMimeType(path);
+ return File(System.IO.File.OpenRead(path), contentType);
+ }
+
+ /// <summary>
+ /// Get all general images.
+ /// </summary>
+ /// <response code="200">Retrieved list of images.</response>
+ /// <returns>An <see cref="OkResult"/> containing the list of images.</returns>
+ [HttpGet("Ratings")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<ImageByNameInfo>> GetRatingImages()
+ {
+ return GetImageList(_applicationPaths.RatingsPath, false);
+ }
+
+ /// <summary>
+ /// Get rating image.
+ /// </summary>
+ /// <param name="theme">The theme to get the image from.</param>
+ /// <param name="name">The name of the image.</param>
+ /// <response code="200">Image stream retrieved.</response>
+ /// <response code="404">Image not found.</response>
+ /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+ [HttpGet("Ratings/{Theme}/{Name}")]
+ [AllowAnonymous]
+ [Produces(MediaTypeNames.Application.Octet)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<FileStreamResult> GetRatingImage(
+ [FromRoute] string theme,
+ [FromRoute] string name)
+ {
+ return GetImageFile(_applicationPaths.RatingsPath, theme, name);
+ }
+
+ /// <summary>
+ /// Get all media info images.
+ /// </summary>
+ /// <response code="200">Image list retrieved.</response>
+ /// <returns>An <see cref="OkResult"/> containing the list of images.</returns>
+ [HttpGet("MediaInfo")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<ImageByNameInfo>> GetMediaInfoImages()
+ {
+ return GetImageList(_applicationPaths.MediaInfoImagesPath, false);
+ }
+
+ /// <summary>
+ /// Get media info image.
+ /// </summary>
+ /// <param name="theme">The theme to get the image from.</param>
+ /// <param name="name">The name of the image.</param>
+ /// <response code="200">Image stream retrieved.</response>
+ /// <response code="404">Image not found.</response>
+ /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+ [HttpGet("MediaInfo/{Theme}/{Name}")]
+ [AllowAnonymous]
+ [Produces(MediaTypeNames.Application.Octet)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<FileStreamResult> GetMediaInfoImage(
+ [FromRoute] string theme,
+ [FromRoute] string name)
+ {
+ return GetImageFile(_applicationPaths.MediaInfoImagesPath, theme, name);
+ }
+
+ /// <summary>
+ /// Internal FileHelper.
+ /// </summary>
+ /// <param name="basePath">Path to begin search.</param>
+ /// <param name="theme">Theme to search.</param>
+ /// <param name="name">File name to search for.</param>
+ /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
+ private ActionResult<FileStreamResult> GetImageFile(string basePath, string theme, string name)
+ {
+ var themeFolder = Path.Combine(basePath, theme);
+ if (Directory.Exists(themeFolder))
+ {
+ var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(themeFolder, name + i))
+ .FirstOrDefault(System.IO.File.Exists);
+
+ if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
+ {
+ var contentType = MimeTypes.GetMimeType(path);
+ return File(System.IO.File.OpenRead(path), contentType);
+ }
+ }
+
+ var allFolder = Path.Combine(basePath, "all");
+ if (Directory.Exists(allFolder))
+ {
+ var path = BaseItem.SupportedImageExtensions.Select(i => Path.Combine(allFolder, name + i))
+ .FirstOrDefault(System.IO.File.Exists);
+
+ if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
+ {
+ var contentType = MimeTypes.GetMimeType(path);
+ return File(System.IO.File.OpenRead(path), contentType);
+ }
+ }
+
+ return NotFound();
+ }
+
+ private List<ImageByNameInfo> GetImageList(string path, bool supportsThemes)
+ {
+ try
+ {
+ return _fileSystem.GetFiles(path, BaseItem.SupportedImageExtensions, false, true)
+ .Select(i => new ImageByNameInfo
+ {
+ Name = _fileSystem.GetFileNameWithoutExtension(i),
+ FileLength = i.Length,
+
+ // For themeable images, use the Theme property
+ // For general images, the same object structure is fine,
+ // but it's not owned by a theme, so call it Context
+ Theme = supportsThemes ? GetThemeName(i.FullName, path) : null,
+ Context = supportsThemes ? null : GetThemeName(i.FullName, path),
+ Format = i.Extension.ToLowerInvariant().TrimStart('.')
+ })
+ .OrderBy(i => i.Name)
+ .ToList();
+ }
+ catch (IOException)
+ {
+ return new List<ImageByNameInfo>();
+ }
+ }
+
+ private string? GetThemeName(string path, string rootImagePath)
+ {
+ var parentName = Path.GetDirectoryName(path);
+
+ if (string.Equals(parentName, rootImagePath, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ parentName = Path.GetFileName(parentName);
+
+ return string.Equals(parentName, "all", StringComparison.OrdinalIgnoreCase) ? null : parentName;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs
new file mode 100644
index 000000000..a1df22e41
--- /dev/null
+++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs
@@ -0,0 +1,88 @@
+#pragma warning disable CA1801
+
+using System.ComponentModel;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Item Refresh Controller.
+ /// </summary>
+ /// [Authenticated]
+ [Route("/Items")]
+ [Authorize]
+ public class ItemRefreshController : BaseJellyfinApiController
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IProviderManager _providerManager;
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ItemRefreshController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
+ public ItemRefreshController(
+ ILibraryManager libraryManager,
+ IProviderManager providerManager,
+ IFileSystem fileSystem)
+ {
+ _libraryManager = libraryManager;
+ _providerManager = providerManager;
+ _fileSystem = fileSystem;
+ }
+
+ /// <summary>
+ /// Refreshes metadata for an item.
+ /// </summary>
+ /// <param name="id">Item id.</param>
+ /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param>
+ /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param>
+ /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param>
+ /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param>
+ /// <param name="recursive">(Unused) Indicates if the refresh should occur recursively.</param>
+ /// <response code="200">Item metadata refresh queued.</response>
+ /// <response code="404">Item to refresh not found.</response>
+ /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
+ [HttpPost("{Id}/Refresh")]
+ [Description("Refreshes metadata for an item.")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult Post(
+ [FromRoute] string id,
+ [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None,
+ [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None,
+ [FromQuery] bool replaceAllMetadata = false,
+ [FromQuery] bool replaceAllImages = false,
+ [FromQuery] bool recursive = false)
+ {
+ var item = _libraryManager.GetItemById(id);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+ {
+ MetadataRefreshMode = metadataRefreshMode,
+ ImageRefreshMode = imageRefreshMode,
+ ReplaceAllImages = replaceAllImages,
+ ReplaceAllMetadata = replaceAllMetadata,
+ ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh
+ || imageRefreshMode == MetadataRefreshMode.FullRefresh
+ || replaceAllImages
+ || replaceAllMetadata,
+ IsAutomated = false
+ };
+
+ _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);
+ return Ok();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
new file mode 100644
index 000000000..ca2905b11
--- /dev/null
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -0,0 +1,341 @@
+#pragma warning disable CA1801
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Progress;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// The library structure controller.
+ /// </summary>
+ [Route("/Library/VirtualFolders")]
+ [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+ public class LibraryStructureController : BaseJellyfinApiController
+ {
+ private readonly IServerApplicationPaths _appPaths;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILibraryMonitor _libraryMonitor;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LibraryStructureController"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param>
+ public LibraryStructureController(
+ IServerConfigurationManager serverConfigurationManager,
+ ILibraryManager libraryManager,
+ ILibraryMonitor libraryMonitor)
+ {
+ _appPaths = serverConfigurationManager.ApplicationPaths;
+ _libraryManager = libraryManager;
+ _libraryMonitor = libraryMonitor;
+ }
+
+ /// <summary>
+ /// Gets all virtual folders.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <response code="200">Virtual folders retrieved.</response>
+ /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns>
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders([FromQuery] string userId)
+ {
+ return _libraryManager.GetVirtualFolders(true);
+ }
+
+ /// <summary>
+ /// Adds a virtual folder.
+ /// </summary>
+ /// <param name="name">The name of the virtual folder.</param>
+ /// <param name="collectionType">The type of the collection.</param>
+ /// <param name="refreshLibrary">Whether to refresh the library.</param>
+ /// <param name="paths">The paths of the virtual folder.</param>
+ /// <param name="libraryOptions">The library options.</param>
+ /// <response code="204">Folder added.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> AddVirtualFolder(
+ [FromQuery] string name,
+ [FromQuery] string collectionType,
+ [FromQuery] bool refreshLibrary,
+ [FromQuery] string[] paths,
+ [FromQuery] LibraryOptions libraryOptions)
+ {
+ libraryOptions ??= new LibraryOptions();
+
+ if (paths != null && paths.Length > 0)
+ {
+ libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo { Path = i }).ToArray();
+ }
+
+ await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false);
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Removes a virtual folder.
+ /// </summary>
+ /// <param name="name">The name of the folder.</param>
+ /// <param name="refreshLibrary">Whether to refresh the library.</param>
+ /// <response code="204">Folder removed.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> RemoveVirtualFolder(
+ [FromQuery] string name,
+ [FromQuery] bool refreshLibrary)
+ {
+ await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Renames a virtual folder.
+ /// </summary>
+ /// <param name="name">The name of the virtual folder.</param>
+ /// <param name="newName">The new name.</param>
+ /// <param name="refreshLibrary">Whether to refresh the library.</param>
+ /// <response code="204">Folder renamed.</response>
+ /// <response code="404">Library doesn't exist.</response>
+ /// <response code="409">Library already exists.</response>
+ /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
+ /// <exception cref="ArgumentNullException">The new name may not be null.</exception>
+ [HttpPost("Name")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public ActionResult RenameVirtualFolder(
+ [FromQuery] string name,
+ [FromQuery] string newName,
+ [FromQuery] bool refreshLibrary)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+
+ if (string.IsNullOrWhiteSpace(newName))
+ {
+ throw new ArgumentNullException(nameof(newName));
+ }
+
+ var rootFolderPath = _appPaths.DefaultUserViewsPath;
+
+ var currentPath = Path.Combine(rootFolderPath, name);
+ var newPath = Path.Combine(rootFolderPath, newName);
+
+ if (!Directory.Exists(currentPath))
+ {
+ return NotFound("The media collection does not exist.");
+ }
+
+ if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath))
+ {
+ return Conflict($"The media library already exists at {newPath}.");
+ }
+
+ _libraryMonitor.Stop();
+
+ try
+ {
+ // Changing capitalization. Handle windows case insensitivity
+ if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase))
+ {
+ var tempPath = Path.Combine(
+ rootFolderPath,
+ Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
+ Directory.Move(currentPath, tempPath);
+ currentPath = tempPath;
+ }
+
+ Directory.Move(currentPath, newPath);
+ }
+ finally
+ {
+ CollectionFolder.OnCollectionFolderChange();
+
+ Task.Run(async () =>
+ {
+ // No need to start if scanning the library because it will handle it
+ if (refreshLibrary)
+ {
+ await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+ }
+ else
+ {
+ // Need to add a delay here or directory watchers may still pick up the changes
+ // Have to block here to allow exceptions to bubble
+ await Task.Delay(1000).ConfigureAwait(false);
+ _libraryMonitor.Start();
+ }
+ });
+ }
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Add a media path to a library.
+ /// </summary>
+ /// <param name="name">The name of the library.</param>
+ /// <param name="path">The path to add.</param>
+ /// <param name="pathInfo">The path info.</param>
+ /// <param name="refreshLibrary">Whether to refresh the library.</param>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ /// <response code="204">Media path added.</response>
+ /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
+ [HttpPost("Paths")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult AddMediaPath(
+ [FromQuery] string name,
+ [FromQuery] string path,
+ [FromQuery] MediaPathInfo pathInfo,
+ [FromQuery] bool refreshLibrary)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+
+ _libraryMonitor.Stop();
+
+ try
+ {
+ var mediaPath = pathInfo ?? new MediaPathInfo { Path = path };
+
+ _libraryManager.AddMediaPath(name, mediaPath);
+ }
+ finally
+ {
+ Task.Run(async () =>
+ {
+ // No need to start if scanning the library because it will handle it
+ if (refreshLibrary)
+ {
+ await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+ }
+ else
+ {
+ // Need to add a delay here or directory watchers may still pick up the changes
+ // Have to block here to allow exceptions to bubble
+ await Task.Delay(1000).ConfigureAwait(false);
+ _libraryMonitor.Start();
+ }
+ });
+ }
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Updates a media path.
+ /// </summary>
+ /// <param name="name">The name of the library.</param>
+ /// <param name="pathInfo">The path info.</param>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ /// <response code="204">Media path updated.</response>
+ /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
+ [HttpPost("Paths/Update")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult UpdateMediaPath(
+ [FromQuery] string name,
+ [FromQuery] MediaPathInfo pathInfo)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+
+ _libraryManager.UpdateMediaPath(name, pathInfo);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Remove a media path.
+ /// </summary>
+ /// <param name="name">The name of the library.</param>
+ /// <param name="path">The path to remove.</param>
+ /// <param name="refreshLibrary">Whether to refresh the library.</param>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ /// <response code="204">Media path removed.</response>
+ /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
+ [HttpDelete("Paths")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult RemoveMediaPath(
+ [FromQuery] string name,
+ [FromQuery] string path,
+ [FromQuery] bool refreshLibrary)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+
+ _libraryMonitor.Stop();
+
+ try
+ {
+ _libraryManager.RemoveMediaPath(name, path);
+ }
+ finally
+ {
+ Task.Run(async () =>
+ {
+ // No need to start if scanning the library because it will handle it
+ if (refreshLibrary)
+ {
+ await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false);
+ }
+ else
+ {
+ // Need to add a delay here or directory watchers may still pick up the changes
+ // Have to block here to allow exceptions to bubble
+ await Task.Delay(1000).ConfigureAwait(false);
+ _libraryMonitor.Start();
+ }
+ });
+ }
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Update library options.
+ /// </summary>
+ /// <param name="id">The library name.</param>
+ /// <param name="libraryOptions">The library options.</param>
+ /// <response code="204">Library updated.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("LibraryOptions")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult UpdateLibraryOptions(
+ [FromQuery] string id,
+ [FromQuery] LibraryOptions libraryOptions)
+ {
+ var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(id);
+
+ collectionFolder.UpdateLibraryOptions(libraryOptions);
+ return NoContent();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs
new file mode 100644
index 000000000..1466dd3ec
--- /dev/null
+++ b/Jellyfin.Api/Controllers/LocalizationController.cs
@@ -0,0 +1,76 @@
+using System.Collections.Generic;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Localization controller.
+ /// </summary>
+ [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+ public class LocalizationController : BaseJellyfinApiController
+ {
+ private readonly ILocalizationManager _localization;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LocalizationController"/> class.
+ /// </summary>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ public LocalizationController(ILocalizationManager localization)
+ {
+ _localization = localization;
+ }
+
+ /// <summary>
+ /// Gets known cultures.
+ /// </summary>
+ /// <response code="200">Known cultures returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the list of cultures.</returns>
+ [HttpGet("Cultures")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<CultureDto>> GetCultures()
+ {
+ return Ok(_localization.GetCultures());
+ }
+
+ /// <summary>
+ /// Gets known countries.
+ /// </summary>
+ /// <response code="200">Known countries returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the list of countries.</returns>
+ [HttpGet("Countries")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<CountryInfo>> GetCountries()
+ {
+ return Ok(_localization.GetCountries());
+ }
+
+ /// <summary>
+ /// Gets known parental ratings.
+ /// </summary>
+ /// <response code="200">Known parental ratings returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the list of parental ratings.</returns>
+ [HttpGet("ParentalRatings")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<ParentalRating>> GetParentalRatings()
+ {
+ return Ok(_localization.GetParentalRatings());
+ }
+
+ /// <summary>
+ /// Gets localization options.
+ /// </summary>
+ /// <response code="200">Localization options returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the list of localization options.</returns>
+ [HttpGet("Options")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<LocalizationOption>> GetLocalizationOptions()
+ {
+ return Ok(_localization.GetLocalizationOptions());
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs
index 8d82ca10f..a1f9b9e8f 100644
--- a/Jellyfin.Api/Controllers/NotificationsController.cs
+++ b/Jellyfin.Api/Controllers/NotificationsController.cs
@@ -1,4 +1,3 @@
-#nullable enable
#pragma warning disable CA1801
using System;
@@ -6,6 +5,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Jellyfin.Api.Models.NotificationDtos;
+using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Notifications;
using MediaBrowser.Model.Dto;
@@ -99,10 +99,10 @@ namespace Jellyfin.Api.Controllers
/// <param name="description">The description of the notification.</param>
/// <param name="url">The URL of the notification.</param>
/// <param name="level">The level of the notification.</param>
- /// <response code="200">Notification sent.</response>
- /// <returns>An <cref see="OkResult"/>.</returns>
+ /// <response code="204">Notification sent.</response>
+ /// <returns>A <cref see="NoContentResult"/>.</returns>
[HttpPost("Admin")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult CreateAdminNotification(
[FromQuery] string name,
[FromQuery] string description,
@@ -115,13 +115,16 @@ namespace Jellyfin.Api.Controllers
Description = description,
Url = url,
Level = level ?? NotificationLevel.Normal,
- UserIds = _userManager.Users.Where(i => i.Policy.IsAdministrator).Select(i => i.Id).ToArray(),
+ UserIds = _userManager.Users
+ .Where(user => user.HasPermission(PermissionKind.IsAdministrator))
+ .Select(user => user.Id)
+ .ToArray(),
Date = DateTime.UtcNow,
};
_notificationManager.SendNotification(notification, CancellationToken.None);
- return Ok();
+ return NoContent();
}
/// <summary>
@@ -129,15 +132,15 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="userId">The userID.</param>
/// <param name="ids">A comma-separated list of the IDs of notifications which should be set as read.</param>
- /// <response code="200">Notifications set as read.</response>
- /// <returns>An <cref see="OkResult"/>.</returns>
+ /// <response code="204">Notifications set as read.</response>
+ /// <returns>A <cref see="NoContentResult"/>.</returns>
[HttpPost("{UserID}/Read")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SetRead(
[FromRoute] string userId,
[FromQuery] string ids)
{
- return Ok();
+ return NoContent();
}
/// <summary>
@@ -145,15 +148,15 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="userId">The userID.</param>
/// <param name="ids">A comma-separated list of the IDs of notifications which should be set as unread.</param>
- /// <response code="200">Notifications set as unread.</response>
- /// <returns>An <cref see="OkResult"/>.</returns>
+ /// <response code="204">Notifications set as unread.</response>
+ /// <returns>A <cref see="NoContentResult"/>.</returns>
[HttpPost("{UserID}/Unread")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SetUnread(
[FromRoute] string userId,
[FromQuery] string ids)
{
- return Ok();
+ return NoContent();
}
}
}
diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs
index f37319c19..943c23f8e 100644
--- a/Jellyfin.Api/Controllers/PackageController.cs
+++ b/Jellyfin.Api/Controllers/PackageController.cs
@@ -1,5 +1,3 @@
-#nullable enable
-
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
@@ -18,7 +16,7 @@ namespace Jellyfin.Api.Controllers
/// Package Controller.
/// </summary>
[Route("Packages")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class PackageController : BaseJellyfinApiController
{
private readonly IInstallationManager _installationManager;
@@ -72,11 +70,11 @@ namespace Jellyfin.Api.Controllers
/// <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="204">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>
+ /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns>
[HttpPost("/Installed/{Name}")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Authorize(Policy = Policies.RequiresElevation)]
public async Task<ActionResult> InstallPackage(
@@ -98,23 +96,24 @@ namespace Jellyfin.Api.Controllers
await _installationManager.InstallPackage(package).ConfigureAwait(false);
- return Ok();
+ return NoContent();
}
/// <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>
+ /// <response code="204">Installation cancelled.</response>
+ /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns>
[HttpDelete("/Installing/{id}")]
[Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult CancelPackageInstallation(
[FromRoute] [Required] string id)
{
_installationManager.CancelInstallation(new Guid(id));
- return Ok();
+ return NoContent();
}
}
}
diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs
new file mode 100644
index 000000000..fdb2f4c35
--- /dev/null
+++ b/Jellyfin.Api/Controllers/PluginsController.cs
@@ -0,0 +1,188 @@
+#pragma warning disable CA1801
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.PluginDtos;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Model.Plugins;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Plugins controller.
+ /// </summary>
+ [Authorize]
+ public class PluginsController : BaseJellyfinApiController
+ {
+ private readonly IApplicationHost _appHost;
+ private readonly IInstallationManager _installationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginsController"/> class.
+ /// </summary>
+ /// <param name="appHost">Instance of the <see cref="IApplicationHost"/> interface.</param>
+ /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
+ public PluginsController(
+ IApplicationHost appHost,
+ IInstallationManager installationManager)
+ {
+ _appHost = appHost;
+ _installationManager = installationManager;
+ }
+
+ /// <summary>
+ /// Gets a list of currently installed plugins.
+ /// </summary>
+ /// <param name="isAppStoreEnabled">Optional. Unused.</param>
+ /// <response code="200">Installed plugins returned.</response>
+ /// <returns>List of currently installed plugins.</returns>
+ [HttpGet]
+ public ActionResult<IEnumerable<PluginInfo>> GetPlugins([FromRoute] bool? isAppStoreEnabled)
+ {
+ return Ok(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo()));
+ }
+
+ /// <summary>
+ /// Uninstalls a plugin.
+ /// </summary>
+ /// <param name="pluginId">Plugin id.</param>
+ /// <response code="200">Plugin uninstalled.</response>
+ /// <response code="404">Plugin not found.</response>
+ /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+ [HttpDelete("{pluginId}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ public ActionResult UninstallPlugin([FromRoute] Guid pluginId)
+ {
+ var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId);
+ if (plugin == null)
+ {
+ return NotFound();
+ }
+
+ _installationManager.UninstallPlugin(plugin);
+ return Ok();
+ }
+
+ /// <summary>
+ /// Gets plugin configuration.
+ /// </summary>
+ /// <param name="pluginId">Plugin id.</param>
+ /// <response code="200">Plugin configuration returned.</response>
+ /// <response code="404">Plugin not found or plugin configuration not found.</response>
+ /// <returns>Plugin configuration.</returns>
+ [HttpGet("{pluginId}/Configuration")]
+ public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute] Guid pluginId)
+ {
+ if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
+ {
+ return NotFound();
+ }
+
+ return plugin.Configuration;
+ }
+
+ /// <summary>
+ /// Updates plugin configuration.
+ /// </summary>
+ /// <remarks>
+ /// Accepts plugin configuration as JSON body.
+ /// </remarks>
+ /// <param name="pluginId">Plugin id.</param>
+ /// <response code="200">Plugin configuration updated.</response>
+ /// <response code="200">Plugin not found or plugin does not have configuration.</response>
+ /// <returns>
+ /// A <see cref="Task" /> that represents the asynchronous operation to update plugin configuration.
+ /// The task result contains an <see cref="OkResult"/> indicating success, or <see cref="NotFoundResult"/>
+ /// when plugin not found or plugin doesn't have configuration.
+ /// </returns>
+ [HttpPost("{pluginId}/Configuration")]
+ public async Task<ActionResult> UpdatePluginConfiguration([FromRoute] Guid pluginId)
+ {
+ if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
+ {
+ return NotFound();
+ }
+
+ var configuration = (BasePluginConfiguration)await JsonSerializer.DeserializeAsync(Request.Body, plugin.ConfigurationType)
+ .ConfigureAwait(false);
+
+ plugin.UpdateConfiguration(configuration);
+ return Ok();
+ }
+
+ /// <summary>
+ /// Get plugin security info.
+ /// </summary>
+ /// <response code="200">Plugin security info returned.</response>
+ /// <returns>Plugin security info.</returns>
+ [Obsolete("This endpoint should not be used.")]
+ [HttpGet("SecurityInfo")]
+ public ActionResult<PluginSecurityInfo> GetPluginSecurityInfo()
+ {
+ return new PluginSecurityInfo
+ {
+ IsMbSupporter = true,
+ SupporterKey = "IAmTotallyLegit"
+ };
+ }
+
+ /// <summary>
+ /// Updates plugin security info.
+ /// </summary>
+ /// <param name="pluginSecurityInfo">Plugin security info.</param>
+ /// <response code="200">Plugin security info updated.</response>
+ /// <returns>An <see cref="OkResult"/>.</returns>
+ [Obsolete("This endpoint should not be used.")]
+ [HttpPost("SecurityInfo")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ public ActionResult UpdatePluginSecurityInfo([FromBody, BindRequired] PluginSecurityInfo pluginSecurityInfo)
+ {
+ return Ok();
+ }
+
+ /// <summary>
+ /// Gets registration status for a feature.
+ /// </summary>
+ /// <param name="name">Feature name.</param>
+ /// <response code="200">Registration status returned.</response>
+ /// <returns>Mb registration record.</returns>
+ [Obsolete("This endpoint should not be used.")]
+ [HttpPost("RegistrationRecords/{name}")]
+ public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string name)
+ {
+ return new MBRegistrationRecord
+ {
+ IsRegistered = true,
+ RegChecked = true,
+ TrialVersion = false,
+ IsValid = true,
+ RegError = false
+ };
+ }
+
+ /// <summary>
+ /// Gets registration status for a feature.
+ /// </summary>
+ /// <param name="name">Feature name.</param>
+ /// <response code="501">Not implemented.</response>
+ /// <returns>Not Implemented.</returns>
+ /// <exception cref="NotImplementedException">This endpoint is not implemented.</exception>
+ [Obsolete("Paid plugins are not supported")]
+ [HttpGet("/Registrations/{name}")]
+ public ActionResult GetRegistration([FromRoute] string name)
+ {
+ // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins,
+ // delete all these registration endpoints. They are only kept for compatibility.
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs
new file mode 100644
index 000000000..80983ee64
--- /dev/null
+++ b/Jellyfin.Api/Controllers/RemoteImageController.cs
@@ -0,0 +1,265 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Remote Images Controller.
+ /// </summary>
+ [Route("Images")]
+ [Authorize]
+ public class RemoteImageController : BaseJellyfinApiController
+ {
+ private readonly IProviderManager _providerManager;
+ private readonly IServerApplicationPaths _applicationPaths;
+ private readonly IHttpClient _httpClient;
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RemoteImageController"/> class.
+ /// </summary>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param>
+ /// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ public RemoteImageController(
+ IProviderManager providerManager,
+ IServerApplicationPaths applicationPaths,
+ IHttpClient httpClient,
+ ILibraryManager libraryManager)
+ {
+ _providerManager = providerManager;
+ _applicationPaths = applicationPaths;
+ _httpClient = httpClient;
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Gets available remote images for an item.
+ /// </summary>
+ /// <param name="id">Item Id.</param>
+ /// <param name="type">The image type.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="providerName">Optional. The image provider to use.</param>
+ /// <param name="includeAllLanguages">Optional. Include all languages.</param>
+ /// <response code="200">Remote Images returned.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>Remote Image Result.</returns>
+ [HttpGet("{Id}/RemoteImages")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<RemoteImageResult>> GetRemoteImages(
+ [FromRoute] string id,
+ [FromQuery] ImageType? type,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] string providerName,
+ [FromQuery] bool includeAllLanguages)
+ {
+ var item = _libraryManager.GetItemById(id);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ var images = await _providerManager.GetAvailableRemoteImages(
+ item,
+ new RemoteImageQuery(providerName)
+ {
+ IncludeAllLanguages = includeAllLanguages,
+ IncludeDisabledProviders = true,
+ ImageType = type
+ }, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var imageArray = images.ToArray();
+ var allProviders = _providerManager.GetRemoteImageProviderInfo(item);
+ if (type.HasValue)
+ {
+ allProviders = allProviders.Where(o => o.SupportedImages.Contains(type.Value));
+ }
+
+ var result = new RemoteImageResult
+ {
+ TotalRecordCount = imageArray.Length,
+ Providers = allProviders.Select(o => o.Name)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray()
+ };
+
+ if (startIndex.HasValue)
+ {
+ imageArray = imageArray.Skip(startIndex.Value).ToArray();
+ }
+
+ if (limit.HasValue)
+ {
+ imageArray = imageArray.Take(limit.Value).ToArray();
+ }
+
+ result.Images = imageArray;
+ return result;
+ }
+
+ /// <summary>
+ /// Gets available remote image providers for an item.
+ /// </summary>
+ /// <param name="id">Item Id.</param>
+ /// <response code="200">Returned remote image providers.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>List of remote image providers.</returns>
+ [HttpGet("{Id}/RemoteImages/Providers")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute] string id)
+ {
+ var item = _libraryManager.GetItemById(id);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return Ok(_providerManager.GetRemoteImageProviderInfo(item));
+ }
+
+ /// <summary>
+ /// Gets a remote image.
+ /// </summary>
+ /// <param name="imageUrl">The image url.</param>
+ /// <response code="200">Remote image returned.</response>
+ /// <response code="404">Remote image not found.</response>
+ /// <returns>Image Stream.</returns>
+ [HttpGet("Remote")]
+ [Produces(MediaTypeNames.Application.Octet)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<FileStreamResult>> GetRemoteImage([FromQuery, BindRequired] string imageUrl)
+ {
+ var urlHash = imageUrl.GetMD5();
+ var pointerCachePath = GetFullCachePath(urlHash.ToString());
+
+ string? contentPath = null;
+ var hasFile = false;
+
+ try
+ {
+ contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false);
+ if (System.IO.File.Exists(contentPath))
+ {
+ hasFile = true;
+ }
+ }
+ catch (FileNotFoundException)
+ {
+ // The file isn't cached yet
+ }
+ catch (IOException)
+ {
+ // The file isn't cached yet
+ }
+
+ if (!hasFile)
+ {
+ await DownloadImage(imageUrl, urlHash, pointerCachePath).ConfigureAwait(false);
+ contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false);
+ }
+
+ if (string.IsNullOrEmpty(contentPath))
+ {
+ return NotFound();
+ }
+
+ var contentType = MimeTypes.GetMimeType(contentPath);
+ return File(System.IO.File.OpenRead(contentPath), contentType);
+ }
+
+ /// <summary>
+ /// Downloads a remote image for an item.
+ /// </summary>
+ /// <param name="id">Item Id.</param>
+ /// <param name="type">The image type.</param>
+ /// <param name="imageUrl">The image url.</param>
+ /// <response code="200">Remote image downloaded.</response>
+ /// <response code="404">Remote image not found.</response>
+ /// <returns>Download status.</returns>
+ [HttpPost("{Id}/RemoteImages/Download")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> DownloadRemoteImage(
+ [FromRoute] string id,
+ [FromQuery, BindRequired] ImageType type,
+ [FromQuery] string imageUrl)
+ {
+ var item = _libraryManager.GetItemById(id);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None);
+ return Ok();
+ }
+
+ /// <summary>
+ /// Gets the full cache path.
+ /// </summary>
+ /// <param name="filename">The filename.</param>
+ /// <returns>System.String.</returns>
+ private string GetFullCachePath(string filename)
+ {
+ return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename);
+ }
+
+ /// <summary>
+ /// Downloads the image.
+ /// </summary>
+ /// <param name="url">The URL.</param>
+ /// <param name="urlHash">The URL hash.</param>
+ /// <param name="pointerCachePath">The pointer cache path.</param>
+ /// <returns>Task.</returns>
+ private async Task DownloadImage(string url, Guid urlHash, string pointerCachePath)
+ {
+ using var result = await _httpClient.GetResponse(new HttpRequestOptions
+ {
+ Url = url,
+ BufferContent = false
+ }).ConfigureAwait(false);
+ var ext = result.ContentType.Split('/').Last();
+
+ var fullCachePath = GetFullCachePath(urlHash + "." + ext);
+
+ Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath));
+ await using (var stream = result.Content)
+ {
+ await using var fileStream = new FileStream(fullCachePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
+ await stream.CopyToAsync(fileStream).ConfigureAwait(false);
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath));
+ await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs
index ec05e4fb4..d971889db 100644
--- a/Jellyfin.Api/Controllers/SearchController.cs
+++ b/Jellyfin.Api/Controllers/SearchController.cs
@@ -3,6 +3,7 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
+using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
@@ -23,7 +24,7 @@ namespace Jellyfin.Api.Controllers
/// Search controller.
/// </summary>
[Route("/Search/Hints")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class SearchController : BaseJellyfinApiController
{
private readonly ISearchEngine _searchEngine;
diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs
index 57a02e62a..d96b0f993 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -33,16 +33,16 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Completes the startup wizard.
/// </summary>
- /// <response code="200">Startup wizard completed.</response>
- /// <returns>An <see cref="OkResult"/> indicating success.</returns>
+ /// <response code="204">Startup wizard completed.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Complete")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult CompleteWizard()
{
_config.Configuration.IsStartupWizardCompleted = true;
_config.SetOptimalValues();
_config.SaveConfiguration();
- return Ok();
+ return NoContent();
}
/// <summary>
@@ -70,10 +70,10 @@ namespace Jellyfin.Api.Controllers
/// <param name="uiCulture">The UI language culture.</param>
/// <param name="metadataCountryCode">The metadata country code.</param>
/// <param name="preferredMetadataLanguage">The preferred language for metadata.</param>
- /// <response code="200">Configuration saved.</response>
- /// <returns>An <see cref="OkResult"/> indicating success.</returns>
+ /// <response code="204">Configuration saved.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Configuration")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateInitialConfiguration(
[FromForm] string uiCulture,
[FromForm] string metadataCountryCode,
@@ -83,7 +83,7 @@ namespace Jellyfin.Api.Controllers
_config.Configuration.MetadataCountryCode = metadataCountryCode;
_config.Configuration.PreferredMetadataLanguage = preferredMetadataLanguage;
_config.SaveConfiguration();
- return Ok();
+ return NoContent();
}
/// <summary>
@@ -91,16 +91,16 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="enableRemoteAccess">Enable remote access.</param>
/// <param name="enableAutomaticPortMapping">Enable UPnP.</param>
- /// <response code="200">Configuration saved.</response>
- /// <returns>An <see cref="OkResult"/> indicating success.</returns>
+ /// <response code="204">Configuration saved.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("RemoteAccess")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping)
{
_config.Configuration.EnableRemoteAccess = enableRemoteAccess;
_config.Configuration.EnableUPnP = enableAutomaticPortMapping;
_config.SaveConfiguration();
- return Ok();
+ return NoContent();
}
/// <summary>
@@ -113,35 +113,41 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<StartupUserDto> GetFirstUser()
{
+ // TODO: Remove this method when startup wizard no longer requires an existing user.
+ _userManager.Initialize();
var user = _userManager.Users.First();
- return new StartupUserDto { Name = user.Name, Password = user.Password };
+ return new StartupUserDto
+ {
+ Name = user.Username,
+ Password = user.Password
+ };
}
/// <summary>
/// Sets the user name and password.
/// </summary>
/// <param name="startupUserDto">The DTO containing username and password.</param>
- /// <response code="200">Updated user name and password.</response>
+ /// <response code="204">Updated user name and password.</response>
/// <returns>
/// A <see cref="Task" /> that represents the asynchronous update operation.
- /// The task result contains an <see cref="OkResult"/> indicating success.
+ /// The task result contains a <see cref="NoContentResult"/> indicating success.
/// </returns>
[HttpPost("User")]
- [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> UpdateUser([FromForm] StartupUserDto startupUserDto)
{
var user = _userManager.Users.First();
- user.Name = startupUserDto.Name;
+ user.Username = startupUserDto.Name;
- _userManager.UpdateUser(user);
+ await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
if (!string.IsNullOrEmpty(startupUserDto.Password))
{
await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
}
- return Ok();
+ return NoContent();
}
}
}
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
new file mode 100644
index 000000000..69b83379d
--- /dev/null
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -0,0 +1,348 @@
+#pragma warning disable CA1801
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Subtitle controller.
+ /// </summary>
+ public class SubtitleController : BaseJellyfinApiController
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly ISubtitleManager _subtitleManager;
+ private readonly ISubtitleEncoder _subtitleEncoder;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IProviderManager _providerManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly IAuthorizationContext _authContext;
+ private readonly ILogger<SubtitleController> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SubtitleController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param>
+ /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param>
+ /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
+ /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
+ /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
+ public SubtitleController(
+ ILibraryManager libraryManager,
+ ISubtitleManager subtitleManager,
+ ISubtitleEncoder subtitleEncoder,
+ IMediaSourceManager mediaSourceManager,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ IAuthorizationContext authContext,
+ ILogger<SubtitleController> logger)
+ {
+ _libraryManager = libraryManager;
+ _subtitleManager = subtitleManager;
+ _subtitleEncoder = subtitleEncoder;
+ _mediaSourceManager = mediaSourceManager;
+ _providerManager = providerManager;
+ _fileSystem = fileSystem;
+ _authContext = authContext;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Deletes an external subtitle file.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="index">The index of the subtitle file.</param>
+ /// <response code="204">Subtitle deleted.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("/Videos/{id}/Subtitles/{index}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<Task> DeleteSubtitle(
+ [FromRoute] Guid id,
+ [FromRoute] int index)
+ {
+ var item = _libraryManager.GetItemById(id);
+
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ _subtitleManager.DeleteSubtitles(item, index);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Search remote subtitles.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="language">The language of the subtitles.</param>
+ /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param>
+ /// <response code="200">Subtitles retrieved.</response>
+ /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns>
+ [HttpGet("/Items/{id}/RemoteSearch/Subtitles/{language}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
+ [FromRoute] Guid id,
+ [FromRoute] string language,
+ [FromQuery] bool? isPerfectMatch)
+ {
+ var video = (Video)_libraryManager.GetItemById(id);
+
+ return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Downloads a remote subtitle.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="subtitleId">The subtitle id.</param>
+ /// <response code="204">Subtitle downloaded.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("/Items/{id}/RemoteSearch/Subtitles/{subtitleId}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> DownloadRemoteSubtitles(
+ [FromRoute] Guid id,
+ [FromRoute] string subtitleId)
+ {
+ var video = (Video)_libraryManager.GetItemById(id);
+
+ try
+ {
+ await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error downloading subtitles");
+ }
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Gets the remote subtitles.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <response code="200">File returned.</response>
+ /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns>
+ [HttpGet("/Providers/Subtitles/Subtitles/{id}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Produces(MediaTypeNames.Application.Octet)]
+ public async Task<ActionResult> GetRemoteSubtitles([FromRoute] string id)
+ {
+ var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
+
+ return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format));
+ }
+
+ /// <summary>
+ /// Gets subtitles in a specified format.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="mediaSourceId">The media source id.</param>
+ /// <param name="index">The subtitle stream index.</param>
+ /// <param name="format">The format of the returned subtitle.</param>
+ /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param>
+ /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
+ /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
+ /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
+ /// <response code="200">File returned.</response>
+ /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
+ [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
+ [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult> GetSubtitle(
+ [FromRoute, Required] Guid id,
+ [FromRoute, Required] string mediaSourceId,
+ [FromRoute, Required] int index,
+ [FromRoute, Required] string format,
+ [FromRoute] long startPositionTicks,
+ [FromQuery] long? endPositionTicks,
+ [FromQuery] bool copyTimestamps,
+ [FromQuery] bool addVttTimeMap)
+ {
+ if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
+ {
+ format = "json";
+ }
+
+ if (string.IsNullOrEmpty(format))
+ {
+ var item = (Video)_libraryManager.GetItemById(id);
+
+ var idString = id.ToString("N", CultureInfo.InvariantCulture);
+ var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false)
+ .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal));
+
+ var subtitleStream = mediaSource.MediaStreams
+ .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index);
+
+ FileStream stream = new FileStream(subtitleStream.Path, FileMode.Open, FileAccess.Read);
+ return File(stream, MimeTypes.GetMimeType(subtitleStream.Path));
+ }
+
+ if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
+ {
+ await using Stream stream = await EncodeSubtitles(id, mediaSourceId, index, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
+ using var reader = new StreamReader(stream);
+
+ var text = await reader.ReadToEndAsync().ConfigureAwait(false);
+
+ text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal);
+
+ return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format));
+ }
+
+ return File(
+ await EncodeSubtitles(
+ id,
+ mediaSourceId,
+ index,
+ format,
+ startPositionTicks,
+ endPositionTicks,
+ copyTimestamps).ConfigureAwait(false),
+ MimeTypes.GetMimeType("file." + format));
+ }
+
+ /// <summary>
+ /// Gets an HLS subtitle playlist.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="index">The subtitle stream index.</param>
+ /// <param name="mediaSourceId">The media source id.</param>
+ /// <param name="segmentLength">The subtitle segment length.</param>
+ /// <response code="200">Subtitle playlist retrieved.</response>
+ /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns>
+ [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult> GetSubtitlePlaylist(
+ [FromRoute] Guid id,
+ // TODO: 'int index' is never used: CA1801 is disabled
+ [FromRoute] int index,
+ [FromRoute] string mediaSourceId,
+ [FromQuery, Required] int segmentLength)
+ {
+ var item = (Video)_libraryManager.GetItemById(id);
+
+ var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false);
+
+ var builder = new StringBuilder();
+
+ var runtime = mediaSource.RunTimeTicks ?? -1;
+
+ if (runtime <= 0)
+ {
+ throw new ArgumentException("HLS Subtitles are not supported for this media.");
+ }
+
+ var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks;
+ if (segmentLengthTicks <= 0)
+ {
+ throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)");
+ }
+
+ builder.AppendLine("#EXTM3U");
+ builder.AppendLine("#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture));
+ builder.AppendLine("#EXT-X-VERSION:3");
+ builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0");
+ builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
+
+ long positionTicks = 0;
+
+ var accessToken = _authContext.GetAuthorizationInfo(Request).Token;
+
+ while (positionTicks < runtime)
+ {
+ var remaining = runtime - positionTicks;
+ var lengthTicks = Math.Min(remaining, segmentLengthTicks);
+
+ builder.AppendLine("#EXTINF:" + TimeSpan.FromTicks(lengthTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture) + ",");
+
+ var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks);
+
+ var url = string.Format(
+ CultureInfo.CurrentCulture,
+ "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}",
+ positionTicks.ToString(CultureInfo.InvariantCulture),
+ endPositionTicks.ToString(CultureInfo.InvariantCulture),
+ accessToken);
+
+ builder.AppendLine(url);
+
+ positionTicks += segmentLengthTicks;
+ }
+
+ builder.AppendLine("#EXT-X-ENDLIST");
+ return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
+ }
+
+ /// <summary>
+ /// Encodes a subtitle in the specified format.
+ /// </summary>
+ /// <param name="id">The media id.</param>
+ /// <param name="mediaSourceId">The source media id.</param>
+ /// <param name="index">The subtitle index.</param>
+ /// <param name="format">The format to convert to.</param>
+ /// <param name="startPositionTicks">The start position in ticks.</param>
+ /// <param name="endPositionTicks">The end position in ticks.</param>
+ /// <param name="copyTimestamps">Whether to copy the timestamps.</param>
+ /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns>
+ private Task<Stream> EncodeSubtitles(
+ Guid id,
+ string mediaSourceId,
+ int index,
+ string format,
+ long startPositionTicks,
+ long? endPositionTicks,
+ bool copyTimestamps)
+ {
+ var item = _libraryManager.GetItemById(id);
+
+ return _subtitleEncoder.GetSubtitles(
+ item,
+ mediaSourceId,
+ index,
+ format,
+ startPositionTicks,
+ endPositionTicks ?? 0,
+ copyTimestamps,
+ CancellationToken.None);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
index 86d9322fe..2528fd75d 100644
--- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
+++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
@@ -1,9 +1,8 @@
-#nullable enable
-
using System;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
@@ -17,7 +16,7 @@ namespace Jellyfin.Api.Controllers
/// Attachments controller.
/// </summary>
[Route("Videos")]
- [Authorize]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
public class VideoAttachmentsController : BaseJellyfinApiController
{
private readonly ILibraryManager _libraryManager;
diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs
new file mode 100644
index 000000000..a07d4ed82
--- /dev/null
+++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs
@@ -0,0 +1,77 @@
+#nullable enable
+
+using System;
+using System.Linq;
+using System.Security.Claims;
+using Jellyfin.Api.Constants;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// Claim Helpers.
+ /// </summary>
+ public static class ClaimHelpers
+ {
+ /// <summary>
+ /// Get user id from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>User id.</returns>
+ public static Guid? GetUserId(in ClaimsPrincipal user)
+ {
+ var value = GetClaimValue(user, InternalClaimTypes.UserId);
+ return string.IsNullOrEmpty(value)
+ ? null
+ : (Guid?)Guid.Parse(value);
+ }
+
+ /// <summary>
+ /// Get device id from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>Device id.</returns>
+ public static string? GetDeviceId(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.DeviceId);
+
+ /// <summary>
+ /// Get device from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>Device.</returns>
+ public static string? GetDevice(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Device);
+
+ /// <summary>
+ /// Get client from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>Client.</returns>
+ public static string? GetClient(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Client);
+
+ /// <summary>
+ /// Get version from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>Version.</returns>
+ public static string? GetVersion(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Version);
+
+ /// <summary>
+ /// Get token from claims.
+ /// </summary>
+ /// <param name="user">Current claims principal.</param>
+ /// <returns>Token.</returns>
+ public static string? GetToken(in ClaimsPrincipal user)
+ => GetClaimValue(user, InternalClaimTypes.Token);
+
+ private static string? GetClaimValue(in ClaimsPrincipal user, string name)
+ {
+ return user?.Identities
+ .SelectMany(c => c.Claims)
+ .Where(claim => claim.Type.Equals(name, StringComparison.OrdinalIgnoreCase))
+ .Select(claim => claim.Value)
+ .FirstOrDefault();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
index 3706a11e3..3b827ec12 100644
--- a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
+++ b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
@@ -1,5 +1,3 @@
-#nullable enable
-
namespace Jellyfin.Api.Models.ConfigurationDtos
{
/// <summary>
diff --git a/Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs b/Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs
index 502b22623..af5239ec2 100644
--- a/Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs
+++ b/Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs
@@ -1,5 +1,3 @@
-#nullable enable
-
using System;
using MediaBrowser.Model.Notifications;
diff --git a/Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs b/Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs
index e34e176cb..64e92bd83 100644
--- a/Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs
+++ b/Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs
@@ -1,5 +1,3 @@
-#nullable enable
-
using System;
using System.Collections.Generic;
diff --git a/Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs b/Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs
index b3746ee2d..0568dea66 100644
--- a/Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs
+++ b/Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs
@@ -1,5 +1,3 @@
-#nullable enable
-
using MediaBrowser.Model.Notifications;
namespace Jellyfin.Api.Models.NotificationDtos
diff --git a/Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs b/Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs
new file mode 100644
index 000000000..7f1255f4b
--- /dev/null
+++ b/Jellyfin.Api/Models/PluginDtos/MBRegistrationRecord.cs
@@ -0,0 +1,40 @@
+using System;
+
+namespace Jellyfin.Api.Models.PluginDtos
+{
+ /// <summary>
+ /// MB Registration Record.
+ /// </summary>
+ public class MBRegistrationRecord
+ {
+ /// <summary>
+ /// Gets or sets expiration date.
+ /// </summary>
+ public DateTime ExpirationDate { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether is registered.
+ /// </summary>
+ public bool IsRegistered { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether reg checked.
+ /// </summary>
+ public bool RegChecked { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether reg error.
+ /// </summary>
+ public bool RegError { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether trial version.
+ /// </summary>
+ public bool TrialVersion { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether is valid.
+ /// </summary>
+ public bool IsValid { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs b/Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs
new file mode 100644
index 000000000..a90398425
--- /dev/null
+++ b/Jellyfin.Api/Models/PluginDtos/PluginSecurityInfo.cs
@@ -0,0 +1,18 @@
+namespace Jellyfin.Api.Models.PluginDtos
+{
+ /// <summary>
+ /// Plugin security info.
+ /// </summary>
+ public class PluginSecurityInfo
+ {
+ /// <summary>
+ /// Gets or sets the supporter key.
+ /// </summary>
+ public string? SupporterKey { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether is mb supporter.
+ /// </summary>
+ public bool IsMbSupporter { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs
index 5a83a030d..a5f012245 100644
--- a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs
+++ b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs
@@ -1,5 +1,3 @@
-#nullable disable
-
namespace Jellyfin.Api.Models.StartupDtos
{
/// <summary>
@@ -10,16 +8,16 @@ namespace Jellyfin.Api.Models.StartupDtos
/// <summary>
/// Gets or sets UI language culture.
/// </summary>
- public string UICulture { get; set; }
+ public string? UICulture { get; set; }
/// <summary>
/// Gets or sets the metadata country code.
/// </summary>
- public string MetadataCountryCode { get; set; }
+ public string? MetadataCountryCode { get; set; }
/// <summary>
/// Gets or sets the preferred language for the metadata.
/// </summary>
- public string PreferredMetadataLanguage { get; set; }
+ public string? PreferredMetadataLanguage { get; set; }
}
}
diff --git a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs
index 0dbb245ec..e4c973548 100644
--- a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs
+++ b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs
@@ -1,5 +1,3 @@
-#nullable disable
-
namespace Jellyfin.Api.Models.StartupDtos
{
/// <summary>
@@ -10,11 +8,11 @@ namespace Jellyfin.Api.Models.StartupDtos
/// <summary>
/// Gets or sets the username.
/// </summary>
- public string Name { get; set; }
+ public string? Name { get; set; }
/// <summary>
/// Gets or sets the user's password.
/// </summary>
- public string Password { get; set; }
+ public string? Password { get; set; }
}
}