repo creation with initial code after cloning public repo
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
#pragma warning disable CA1813 // Avoid unsealed attributes
|
||||
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Api.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Internal produces image attribute.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public class AcceptsFileAttribute : Attribute
|
||||
{
|
||||
private readonly string[] _contentTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AcceptsFileAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="contentTypes">Content types this endpoint produces.</param>
|
||||
public AcceptsFileAttribute(params string[] contentTypes)
|
||||
{
|
||||
_contentTypes = contentTypes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured content types.
|
||||
/// </summary>
|
||||
/// <returns>the configured content types.</returns>
|
||||
public string[] ContentTypes => _contentTypes;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Jellyfin.Api.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Produces file attribute of "image/*".
|
||||
/// </summary>
|
||||
public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute
|
||||
{
|
||||
private const string ContentType = "image/*";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AcceptsImageFileAttribute"/> class.
|
||||
/// </summary>
|
||||
public AcceptsImageFileAttribute()
|
||||
: base(ContentType)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Api.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute to mark a parameter as obsolete.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Parameter)]
|
||||
public sealed class ParameterObsoleteAttribute : Attribute
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Jellyfin.Api.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Produces file attribute of "image/*".
|
||||
/// </summary>
|
||||
public sealed class ProducesAudioFileAttribute : ProducesFileAttribute
|
||||
{
|
||||
private const string ContentType = "audio/*";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProducesAudioFileAttribute"/> class.
|
||||
/// </summary>
|
||||
public ProducesAudioFileAttribute()
|
||||
: base(ContentType)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma warning disable CA1813 // Avoid unsealed attributes
|
||||
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.Api.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Internal produces image attribute.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public class ProducesFileAttribute : Attribute
|
||||
{
|
||||
private readonly string[] _contentTypes;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProducesFileAttribute"/> class.
|
||||
/// </summary>
|
||||
/// <param name="contentTypes">Content types this endpoint produces.</param>
|
||||
public ProducesFileAttribute(params string[] contentTypes)
|
||||
{
|
||||
_contentTypes = contentTypes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured content types.
|
||||
/// </summary>
|
||||
/// <returns>the configured content types.</returns>
|
||||
public string[] ContentTypes => _contentTypes;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Jellyfin.Api.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Produces file attribute of "image/*".
|
||||
/// </summary>
|
||||
public sealed class ProducesImageFileAttribute : ProducesFileAttribute
|
||||
{
|
||||
private const string ContentType = "image/*";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProducesImageFileAttribute"/> class.
|
||||
/// </summary>
|
||||
public ProducesImageFileAttribute()
|
||||
: base(ContentType)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Jellyfin.Api.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Produces file attribute of "image/*".
|
||||
/// </summary>
|
||||
public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute
|
||||
{
|
||||
private const string ContentType = "application/x-mpegURL";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProducesPlaylistFileAttribute"/> class.
|
||||
/// </summary>
|
||||
public ProducesPlaylistFileAttribute()
|
||||
: base(ContentType)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Jellyfin.Api.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Produces file attribute of "video/*".
|
||||
/// </summary>
|
||||
public sealed class ProducesVideoFileAttribute : ProducesFileAttribute
|
||||
{
|
||||
private const string ContentType = "video/*";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProducesVideoFileAttribute"/> class.
|
||||
/// </summary>
|
||||
public ProducesVideoFileAttribute()
|
||||
: base(ContentType)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Api.Auth.AnonymousLanAccessPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// LAN access handler. Allows anonymous users.
|
||||
/// </summary>
|
||||
public class AnonymousLanAccessHandler : AuthorizationHandler<AnonymousLanAccessRequirement>
|
||||
{
|
||||
private readonly INetworkManager _networkManager;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AnonymousLanAccessHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
|
||||
/// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
|
||||
public AnonymousLanAccessHandler(
|
||||
INetworkManager networkManager,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_networkManager = networkManager;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AnonymousLanAccessRequirement requirement)
|
||||
{
|
||||
var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIP();
|
||||
|
||||
// Loopback will be on LAN, so we can accept null.
|
||||
if (ip is null || _networkManager.IsInLocalNetwork(ip))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Fail();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Jellyfin.Api.Auth.AnonymousLanAccessPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// The local network authorization requirement. Allows anonymous users.
|
||||
/// </summary>
|
||||
public class AnonymousLanAccessRequirement : IAuthorizationRequirement
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jellyfin.Api.Auth
|
||||
{
|
||||
/// <summary>
|
||||
/// Custom authentication handler wrapping the legacy authentication.
|
||||
/// </summary>
|
||||
public class CustomAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
private readonly ILogger<CustomAuthenticationHandler> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CustomAuthenticationHandler" /> class.
|
||||
/// </summary>
|
||||
/// <param name="authService">The jellyfin authentication service.</param>
|
||||
/// <param name="options">Options monitor.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="encoder">The url encoder.</param>
|
||||
public CustomAuthenticationHandler(
|
||||
IAuthService authService,
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_authService = authService;
|
||||
_logger = logger.CreateLogger<CustomAuthenticationHandler>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var authorizationInfo = await _authService.Authenticate(Request).ConfigureAwait(false);
|
||||
if (!authorizationInfo.HasToken)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
var role = UserRoles.User;
|
||||
if (authorizationInfo.IsApiKey
|
||||
|| (authorizationInfo.User?.HasPermission(PermissionKind.IsAdministrator) ?? false))
|
||||
{
|
||||
role = UserRoles.Administrator;
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, authorizationInfo.User?.Username ?? string.Empty),
|
||||
new Claim(ClaimTypes.Role, role),
|
||||
new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)),
|
||||
new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId ?? string.Empty),
|
||||
new Claim(InternalClaimTypes.Device, authorizationInfo.Device ?? string.Empty),
|
||||
new Claim(InternalClaimTypes.Client, authorizationInfo.Client ?? string.Empty),
|
||||
new Claim(InternalClaimTypes.Version, authorizationInfo.Version ?? string.Empty),
|
||||
new Claim(InternalClaimTypes.Token, authorizationInfo.Token),
|
||||
new Claim(InternalClaimTypes.IsApiKey, authorizationInfo.IsApiKey.ToString(CultureInfo.InvariantCulture))
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
catch (AuthenticationException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error authenticating with {Handler}", nameof(CustomAuthenticationHandler));
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
catch (SecurityException ex)
|
||||
{
|
||||
return AuthenticateResult.Fail(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
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 : AuthorizationHandler<DefaultAuthorizationRequirement>
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly INetworkManager _networkManager;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_networkManager = networkManager;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement)
|
||||
{
|
||||
var isApiKey = context.User.GetIsApiKey();
|
||||
var userId = context.User.GetUserId();
|
||||
// This likely only happens during the wizard, so skip the default checks and let any other handlers do it
|
||||
if (!isApiKey && userId.IsEmpty())
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (isApiKey)
|
||||
{
|
||||
// Api keys are unrestricted.
|
||||
context.Succeed(requirement);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var isInLocalNetwork = _httpContextAccessor.HttpContext is not null
|
||||
&& _networkManager.IsInLocalNetwork(_httpContextAccessor.HttpContext.GetNormalizedRemoteIP());
|
||||
var user = _userManager.GetUserById(userId);
|
||||
if (user is null)
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
// User cannot access remotely and user is remote
|
||||
if (!isInLocalNetwork && !user.HasPermission(PermissionKind.EnableRemoteAccess))
|
||||
{
|
||||
context.Fail();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Admins can do everything
|
||||
if (context.User.IsInRole(UserRoles.Administrator))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// It's not great to have this check, but parental schedule must usually be honored except in a few rare cases
|
||||
if (requirement.ValidateParentalSchedule && !user.IsParentalScheduleAllowed())
|
||||
{
|
||||
context.Fail();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Only succeed if the requirement isn't a subclass as any subclassed requirement will handle success in its own handler
|
||||
if (requirement.GetType() == typeof(DefaultAuthorizationRequirement))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default authorization requirement.
|
||||
/// </summary>
|
||||
public class DefaultAuthorizationRequirement : IAuthorizationRequirement
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultAuthorizationRequirement"/> class.
|
||||
/// </summary>
|
||||
/// <param name="validateParentalSchedule">A value indicating whether to validate parental schedule.</param>
|
||||
public DefaultAuthorizationRequirement(bool validateParentalSchedule = true)
|
||||
{
|
||||
ValidateParentalSchedule = validateParentalSchedule;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to ignore parental schedule.
|
||||
/// </summary>
|
||||
public bool ValidateParentalSchedule { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Authorization handler for requiring first time setup or default privileges.
|
||||
/// </summary>
|
||||
public class FirstTimeSetupHandler : AuthorizationHandler<FirstTimeSetupRequirement>
|
||||
{
|
||||
private readonly IConfigurationManager _configurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FirstTimeSetupHandler" /> class.
|
||||
/// </summary>
|
||||
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
|
||||
public FirstTimeSetupHandler(IConfigurationManager configurationManager)
|
||||
{
|
||||
_configurationManager = configurationManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupRequirement requirement)
|
||||
{
|
||||
// Succeed if the startup wizard / first time setup is not complete
|
||||
if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
// Succeed if user is admin
|
||||
else if (context.User.IsInRole(UserRoles.Administrator))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
// Fail if admin is required and user is not admin
|
||||
else if (requirement.RequireAdmin)
|
||||
{
|
||||
context.Fail();
|
||||
}
|
||||
|
||||
// Succeed if admin is not required and user is not guest
|
||||
else if (context.User.IsInRole(UserRoles.User))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
// Any user-specific checks are handled in the DefaultAuthorizationHandler.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
|
||||
|
||||
namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// The authorization requirement, requiring incomplete first time setup or default privileges, for the authorization handler.
|
||||
/// </summary>
|
||||
public class FirstTimeSetupRequirement : DefaultAuthorizationRequirement
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FirstTimeSetupRequirement"/> class.
|
||||
/// </summary>
|
||||
/// <param name="validateParentalSchedule">A value indicating whether to ignore parental schedule.</param>
|
||||
/// <param name="requireAdmin">A value indicating whether administrator role is required.</param>
|
||||
public FirstTimeSetupRequirement(bool validateParentalSchedule = false, bool requireAdmin = true) : base(validateParentalSchedule)
|
||||
{
|
||||
RequireAdmin = requireAdmin;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether administrator role is required.
|
||||
/// </summary>
|
||||
public bool RequireAdmin { get; }
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Local access or require elevated privileges handler.
|
||||
/// </summary>
|
||||
public class LocalAccessOrRequiresElevationHandler : AuthorizationHandler<LocalAccessOrRequiresElevationRequirement>
|
||||
{
|
||||
private readonly INetworkManager _networkManager;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LocalAccessOrRequiresElevationHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
|
||||
/// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
|
||||
public LocalAccessOrRequiresElevationHandler(
|
||||
INetworkManager networkManager,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_networkManager = networkManager;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessOrRequiresElevationRequirement requirement)
|
||||
{
|
||||
var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIP();
|
||||
|
||||
// Loopback will be on LAN, so we can accept null.
|
||||
if (ip is null || _networkManager.IsInLocalNetwork(ip))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (context.User.IsInRole(UserRoles.Administrator))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
else
|
||||
{
|
||||
context.Fail();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// The local access or elevated privileges authorization requirement.
|
||||
/// </summary>
|
||||
public class LocalAccessOrRequiresElevationRequirement : IAuthorizationRequirement
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.SyncPlay;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Default authorization handler.
|
||||
/// </summary>
|
||||
public class SyncPlayAccessHandler : AuthorizationHandler<SyncPlayAccessRequirement>
|
||||
{
|
||||
private readonly ISyncPlayManager _syncPlayManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SyncPlayAccessHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
public SyncPlayAccessHandler(
|
||||
ISyncPlayManager syncPlayManager,
|
||||
IUserManager userManager)
|
||||
{
|
||||
_syncPlayManager = syncPlayManager;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement)
|
||||
{
|
||||
var userId = context.User.GetUserId();
|
||||
var user = _userManager.GetUserById(userId);
|
||||
if (user is null)
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
if (requirement.RequiredAccess == SyncPlayAccessRequirementType.HasAccess)
|
||||
{
|
||||
if (user.SyncPlayAccess is SyncPlayUserAccessType.CreateAndJoinGroups or SyncPlayUserAccessType.JoinGroups
|
||||
|| _syncPlayManager.IsUserActive(userId))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.CreateGroup)
|
||||
{
|
||||
if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.JoinGroup)
|
||||
{
|
||||
if (user.SyncPlayAccess == SyncPlayUserAccessType.CreateAndJoinGroups
|
||||
|| user.SyncPlayAccess == SyncPlayUserAccessType.JoinGroups)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
else if (requirement.RequiredAccess == SyncPlayAccessRequirementType.IsInGroup)
|
||||
{
|
||||
if (_syncPlayManager.IsUserActive(userId))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
|
||||
using Jellyfin.Data.Enums;
|
||||
|
||||
namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// The default authorization requirement.
|
||||
/// </summary>
|
||||
public class SyncPlayAccessRequirement : DefaultAuthorizationRequirement
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
|
||||
/// </summary>
|
||||
/// <param name="requiredAccess">A value of <see cref="SyncPlayAccessRequirementType"/>.</param>
|
||||
public SyncPlayAccessRequirement(SyncPlayAccessRequirementType requiredAccess)
|
||||
{
|
||||
RequiredAccess = requiredAccess;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the required SyncPlay access.
|
||||
/// </summary>
|
||||
public SyncPlayAccessRequirementType RequiredAccess { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace Jellyfin.Api.Auth.UserPermissionPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// User permission authorization handler.
|
||||
/// </summary>
|
||||
public class UserPermissionHandler : AuthorizationHandler<UserPermissionRequirement>
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserPermissionHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
public UserPermissionHandler(IUserManager userManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement)
|
||||
{
|
||||
// Api keys have global permissions, so just succeed the requirement.
|
||||
if (context.User.GetIsApiKey())
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
else
|
||||
{
|
||||
var userId = context.User.GetUserId();
|
||||
if (!userId.IsEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(context.User.GetUserId());
|
||||
if (user is null)
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
if (user.HasPermission(requirement.RequiredPermission))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
|
||||
namespace Jellyfin.Api.Auth.UserPermissionPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// The user permission requirement.
|
||||
/// </summary>
|
||||
public class UserPermissionRequirement : DefaultAuthorizationRequirement
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserPermissionRequirement"/> class.
|
||||
/// </summary>
|
||||
/// <param name="requiredPermission">The required <see cref="PermissionKind"/>.</param>
|
||||
/// <param name="validateParentalSchedule">Whether to validate the user's parental schedule.</param>
|
||||
public UserPermissionRequirement(PermissionKind requiredPermission, bool validateParentalSchedule = true) : base(validateParentalSchedule)
|
||||
{
|
||||
RequiredPermission = requiredPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the required user permission.
|
||||
/// </summary>
|
||||
public PermissionKind RequiredPermission { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Mime;
|
||||
using Jellyfin.Api.Results;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Base api controller for the API setting a default route.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
[Produces(
|
||||
MediaTypeNames.Application.Json,
|
||||
JsonDefaults.CamelCaseMediaType,
|
||||
JsonDefaults.PascalCaseMediaType)]
|
||||
public class BaseJellyfinApiController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new <see cref="OkResult{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to return.</param>
|
||||
/// <typeparam name="T">The type to return.</typeparam>
|
||||
/// <returns>The <see cref="ActionResult{T}"/>.</returns>
|
||||
protected ActionResult<IEnumerable<T>> Ok<T>(IEnumerable<T>? value)
|
||||
=> new OkResult<IEnumerable<T>?>(value);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="OkResult{T}"/>.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to return.</param>
|
||||
/// <typeparam name="T">The type to return.</typeparam>
|
||||
/// <returns>The <see cref="ActionResult{T}"/>.</returns>
|
||||
protected ActionResult<T> Ok<T>(T value)
|
||||
=> new OkResult<T>(value);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Jellyfin.Api.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication schemes for user authentication in the API.
|
||||
/// </summary>
|
||||
public static class AuthenticationSchemes
|
||||
{
|
||||
/// <summary>
|
||||
/// Scheme name for the custom legacy authentication.
|
||||
/// </summary>
|
||||
public const string CustomAuthentication = "CustomAuthentication";
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Is Api Key.
|
||||
/// </summary>
|
||||
public const string IsApiKey = "Jellyfin-IsApiKey";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Jellyfin.Api.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for user roles used in the authentication and authorization for the API.
|
||||
/// </summary>
|
||||
public static class UserRoles
|
||||
{
|
||||
/// <summary>
|
||||
/// Guest user.
|
||||
/// </summary>
|
||||
public const string Guest = "Guest";
|
||||
|
||||
/// <summary>
|
||||
/// Regular user with no special privileges.
|
||||
/// </summary>
|
||||
public const string User = "User";
|
||||
|
||||
/// <summary>
|
||||
/// Administrator user with elevated privileges.
|
||||
/// </summary>
|
||||
public const string Administrator = "Administrator";
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Data.Queries;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Model.Activity;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Activity log controller.
|
||||
/// </summary>
|
||||
[Route("System/ActivityLog")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public class ActivityLogController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IActivityManager _activityManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ActivityLogController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param>
|
||||
public ActivityLogController(IActivityManager activityManager)
|
||||
{
|
||||
_activityManager = activityManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets activity log entries.
|
||||
/// </summary>
|
||||
/// <param name="startIndex">The record index to start at. All items with a lower index will be dropped from the results.</param>
|
||||
/// <param name="limit">The maximum number of records to return.</param>
|
||||
/// <param name="minDate">The minimum date.</param>
|
||||
/// <param name="maxDate">The maximum date.</param>
|
||||
/// <param name="hasUserId">Filter log entries if it has user id, or not.</param>
|
||||
/// <param name="name">Filter by name.</param>
|
||||
/// <param name="overview">Filter by overview.</param>
|
||||
/// <param name="shortOverview">Filter by short overview.</param>
|
||||
/// <param name="type">Filter by type.</param>
|
||||
/// <param name="itemId">Filter by item id.</param>
|
||||
/// <param name="username">Filter by username.</param>
|
||||
/// <param name="severity">Filter by log severity.</param>
|
||||
/// <param name="sortBy">Specify one or more sort orders. Format: SortBy=Name,Type.</param>
|
||||
/// <param name="sortOrder">Sort Order..</param>
|
||||
/// <response code="200">Activity log returned.</response>
|
||||
/// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
|
||||
[HttpGet("Entries")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<QueryResult<ActivityLogEntry>>> GetLogEntries(
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] DateTime? minDate,
|
||||
[FromQuery] DateTime? maxDate,
|
||||
[FromQuery] bool? hasUserId,
|
||||
[FromQuery] string? name,
|
||||
[FromQuery] string? overview,
|
||||
[FromQuery] string? shortOverview,
|
||||
[FromQuery] string? type,
|
||||
[FromQuery] Guid? itemId,
|
||||
[FromQuery] string? username,
|
||||
[FromQuery] LogLevel? severity,
|
||||
[FromQuery] ActivityLogSortBy[]? sortBy,
|
||||
[FromQuery] SortOrder[]? sortOrder)
|
||||
{
|
||||
var query = new ActivityLogQuery
|
||||
{
|
||||
Skip = startIndex,
|
||||
Limit = limit,
|
||||
MinDate = minDate,
|
||||
MaxDate = maxDate,
|
||||
HasUserId = hasUserId,
|
||||
Name = name,
|
||||
Overview = overview,
|
||||
ShortOverview = shortOverview,
|
||||
Type = type,
|
||||
ItemId = itemId,
|
||||
Username = username,
|
||||
Severity = severity,
|
||||
OrderBy = GetOrderBy(sortBy ?? [], sortOrder ?? []),
|
||||
};
|
||||
|
||||
return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static (ActivityLogSortBy SortBy, SortOrder SortOrder)[] GetOrderBy(
|
||||
IReadOnlyList<ActivityLogSortBy> sortBy,
|
||||
IReadOnlyList<SortOrder> requestedSortOrder)
|
||||
{
|
||||
if (sortBy.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new (ActivityLogSortBy, SortOrder)[sortBy.Count];
|
||||
var i = 0;
|
||||
for (; i < requestedSortOrder.Count; i++)
|
||||
{
|
||||
result[i] = (sortBy[i], requestedSortOrder[i]);
|
||||
}
|
||||
|
||||
// Add remaining elements with the first specified SortOrder
|
||||
// or the default one if no SortOrders are specified
|
||||
var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending;
|
||||
for (; i < sortBy.Count; i++)
|
||||
{
|
||||
result[i] = (sortBy[i], order);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Security;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication controller.
|
||||
/// </summary>
|
||||
[Route("Auth")]
|
||||
public class ApiKeyController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IAuthenticationManager _authenticationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ApiKeyController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authenticationManager">Instance of <see cref="IAuthenticationManager"/> interface.</param>
|
||||
public ApiKeyController(IAuthenticationManager authenticationManager)
|
||||
{
|
||||
_authenticationManager = authenticationManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all keys.
|
||||
/// </summary>
|
||||
/// <response code="200">Api keys retrieved.</response>
|
||||
/// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns>
|
||||
[HttpGet("Keys")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys()
|
||||
{
|
||||
var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false);
|
||||
|
||||
return new QueryResult<AuthenticationInfo>(keys);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new api key.
|
||||
/// </summary>
|
||||
/// <param name="app">Name of the app using the authentication key.</param>
|
||||
/// <response code="204">Api key created.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Keys")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> CreateKey([FromQuery, Required] string app)
|
||||
{
|
||||
await _authenticationManager.CreateApiKey(app).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove an api key.
|
||||
/// </summary>
|
||||
/// <param name="key">The access token to delete.</param>
|
||||
/// <response code="204">Api key deleted.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpDelete("Keys/{key}")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> RevokeKey([FromRoute, Required] string key)
|
||||
{
|
||||
await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The artists controller.
|
||||
/// </summary>
|
||||
[Route("Artists")]
|
||||
[Authorize]
|
||||
public class ArtistsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ArtistsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
public ArtistsController(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDtoService dtoService)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dtoService = dtoService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all artists from a given item, folder, or the entire library.
|
||||
/// </summary>
|
||||
/// <param name="minCommunityRating">Optional filter by minimum community rating.</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="searchTerm">Optional. Search term.</param>
|
||||
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="filters">Optional. Specify additional filters to apply.</param>
|
||||
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
|
||||
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
|
||||
/// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
|
||||
/// <param name="enableUserData">Optional, include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
|
||||
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
|
||||
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
|
||||
/// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
|
||||
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
|
||||
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
|
||||
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
|
||||
/// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
|
||||
/// <param name="enableImages">Optional, include image information in output.</param>
|
||||
/// <param name="enableTotalRecordCount">Total record count.</param>
|
||||
/// <response code="200">Artists returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the artists.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetArtists(
|
||||
[FromQuery] double? minCommunityRating,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? searchTerm,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
|
||||
[FromQuery] bool? isFavorite,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] string? person,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] studios,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? nameStartsWithOrGreater,
|
||||
[FromQuery] string? nameStartsWith,
|
||||
[FromQuery] string? nameLessThan,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
|
||||
[FromQuery] bool? enableImages = true,
|
||||
[FromQuery] bool enableTotalRecordCount = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = null;
|
||||
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
user = _userManager.GetUserById(userId.Value);
|
||||
}
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
MediaTypes = mediaTypes,
|
||||
StartIndex = startIndex,
|
||||
Limit = limit,
|
||||
IsFavorite = isFavorite,
|
||||
NameLessThan = nameLessThan,
|
||||
NameStartsWith = nameStartsWith,
|
||||
NameStartsWithOrGreater = nameStartsWithOrGreater,
|
||||
Tags = tags,
|
||||
OfficialRatings = officialRatings,
|
||||
Genres = genres,
|
||||
GenreIds = genreIds,
|
||||
StudioIds = studioIds,
|
||||
Person = person,
|
||||
PersonIds = personIds,
|
||||
PersonTypes = personTypes,
|
||||
Years = years,
|
||||
MinCommunityRating = minCommunityRating,
|
||||
DtoOptions = dtoOptions,
|
||||
SearchTerm = searchTerm,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
|
||||
};
|
||||
|
||||
if (parentId.HasValue)
|
||||
{
|
||||
if (parentItem is Folder)
|
||||
{
|
||||
query.AncestorIds = new[] { parentId.Value };
|
||||
}
|
||||
else
|
||||
{
|
||||
query.ItemIds = new[] { parentId.Value };
|
||||
}
|
||||
}
|
||||
|
||||
// Studios
|
||||
if (studios.Length != 0)
|
||||
{
|
||||
query.StudioIds = studios.Select(i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return _libraryManager.GetStudio(i);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
|
||||
}
|
||||
|
||||
foreach (var filter in filters)
|
||||
{
|
||||
switch (filter)
|
||||
{
|
||||
case ItemFilter.Dislikes:
|
||||
query.IsLiked = false;
|
||||
break;
|
||||
case ItemFilter.IsFavorite:
|
||||
query.IsFavorite = true;
|
||||
break;
|
||||
case ItemFilter.IsFavoriteOrLikes:
|
||||
query.IsFavoriteOrLiked = true;
|
||||
break;
|
||||
case ItemFilter.IsFolder:
|
||||
query.IsFolder = true;
|
||||
break;
|
||||
case ItemFilter.IsNotFolder:
|
||||
query.IsFolder = false;
|
||||
break;
|
||||
case ItemFilter.IsPlayed:
|
||||
query.IsPlayed = true;
|
||||
break;
|
||||
case ItemFilter.IsResumable:
|
||||
query.IsResumable = true;
|
||||
break;
|
||||
case ItemFilter.IsUnplayed:
|
||||
query.IsPlayed = false;
|
||||
break;
|
||||
case ItemFilter.Likes:
|
||||
query.IsLiked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var result = _libraryManager.GetArtists(query);
|
||||
|
||||
var dtos = result.Items.Select(i =>
|
||||
{
|
||||
var (baseItem, itemCounts) = i;
|
||||
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes.Length != 0)
|
||||
{
|
||||
dto.ChildCount = itemCounts.ItemCount;
|
||||
dto.ProgramCount = itemCounts.ProgramCount;
|
||||
dto.SeriesCount = itemCounts.SeriesCount;
|
||||
dto.EpisodeCount = itemCounts.EpisodeCount;
|
||||
dto.MovieCount = itemCounts.MovieCount;
|
||||
dto.TrailerCount = itemCounts.TrailerCount;
|
||||
dto.AlbumCount = itemCounts.AlbumCount;
|
||||
dto.SongCount = itemCounts.SongCount;
|
||||
dto.ArtistCount = itemCounts.ArtistCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
});
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
query.StartIndex,
|
||||
result.TotalRecordCount,
|
||||
dtos.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all album artists from a given item, folder, or the entire library.
|
||||
/// </summary>
|
||||
/// <param name="minCommunityRating">Optional filter by minimum community rating.</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="searchTerm">Optional. Search term.</param>
|
||||
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="filters">Optional. Specify additional filters to apply.</param>
|
||||
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
|
||||
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
|
||||
/// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
|
||||
/// <param name="enableUserData">Optional, include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
|
||||
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
|
||||
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
|
||||
/// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
|
||||
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
|
||||
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
|
||||
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
|
||||
/// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
|
||||
/// <param name="enableImages">Optional, include image information in output.</param>
|
||||
/// <param name="enableTotalRecordCount">Total record count.</param>
|
||||
/// <response code="200">Album artists returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the album artists.</returns>
|
||||
[HttpGet("AlbumArtists")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists(
|
||||
[FromQuery] double? minCommunityRating,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? searchTerm,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
|
||||
[FromQuery] bool? isFavorite,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] string? person,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] studios,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? nameStartsWithOrGreater,
|
||||
[FromQuery] string? nameStartsWith,
|
||||
[FromQuery] string? nameLessThan,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
|
||||
[FromQuery] bool? enableImages = true,
|
||||
[FromQuery] bool enableTotalRecordCount = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = null;
|
||||
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
user = _userManager.GetUserById(userId.Value);
|
||||
}
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
MediaTypes = mediaTypes,
|
||||
StartIndex = startIndex,
|
||||
Limit = limit,
|
||||
IsFavorite = isFavorite,
|
||||
NameLessThan = nameLessThan,
|
||||
NameStartsWith = nameStartsWith,
|
||||
NameStartsWithOrGreater = nameStartsWithOrGreater,
|
||||
Tags = tags,
|
||||
OfficialRatings = officialRatings,
|
||||
Genres = genres,
|
||||
GenreIds = genreIds,
|
||||
StudioIds = studioIds,
|
||||
Person = person,
|
||||
PersonIds = personIds,
|
||||
PersonTypes = personTypes,
|
||||
Years = years,
|
||||
MinCommunityRating = minCommunityRating,
|
||||
DtoOptions = dtoOptions,
|
||||
SearchTerm = searchTerm,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
|
||||
};
|
||||
|
||||
if (parentId.HasValue)
|
||||
{
|
||||
if (parentItem is Folder)
|
||||
{
|
||||
query.AncestorIds = new[] { parentId.Value };
|
||||
}
|
||||
else
|
||||
{
|
||||
query.ItemIds = new[] { parentId.Value };
|
||||
}
|
||||
}
|
||||
|
||||
// Studios
|
||||
if (studios.Length != 0)
|
||||
{
|
||||
query.StudioIds = studios.Select(i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return _libraryManager.GetStudio(i);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
|
||||
}
|
||||
|
||||
foreach (var filter in filters)
|
||||
{
|
||||
switch (filter)
|
||||
{
|
||||
case ItemFilter.Dislikes:
|
||||
query.IsLiked = false;
|
||||
break;
|
||||
case ItemFilter.IsFavorite:
|
||||
query.IsFavorite = true;
|
||||
break;
|
||||
case ItemFilter.IsFavoriteOrLikes:
|
||||
query.IsFavoriteOrLiked = true;
|
||||
break;
|
||||
case ItemFilter.IsFolder:
|
||||
query.IsFolder = true;
|
||||
break;
|
||||
case ItemFilter.IsNotFolder:
|
||||
query.IsFolder = false;
|
||||
break;
|
||||
case ItemFilter.IsPlayed:
|
||||
query.IsPlayed = true;
|
||||
break;
|
||||
case ItemFilter.IsResumable:
|
||||
query.IsResumable = true;
|
||||
break;
|
||||
case ItemFilter.IsUnplayed:
|
||||
query.IsPlayed = false;
|
||||
break;
|
||||
case ItemFilter.Likes:
|
||||
query.IsLiked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var result = _libraryManager.GetAlbumArtists(query);
|
||||
|
||||
var dtos = result.Items.Select(i =>
|
||||
{
|
||||
var (baseItem, itemCounts) = i;
|
||||
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes.Length != 0)
|
||||
{
|
||||
dto.ChildCount = itemCounts.ItemCount;
|
||||
dto.ProgramCount = itemCounts.ProgramCount;
|
||||
dto.SeriesCount = itemCounts.SeriesCount;
|
||||
dto.EpisodeCount = itemCounts.EpisodeCount;
|
||||
dto.MovieCount = itemCounts.MovieCount;
|
||||
dto.TrailerCount = itemCounts.TrailerCount;
|
||||
dto.AlbumCount = itemCounts.AlbumCount;
|
||||
dto.SongCount = itemCounts.SongCount;
|
||||
dto.ArtistCount = itemCounts.ArtistCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
});
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
query.StartIndex,
|
||||
result.TotalRecordCount,
|
||||
dtos.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an artist by name.
|
||||
/// </summary>
|
||||
/// <param name="name">Studio name.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <response code="200">Artist returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the artist.</returns>
|
||||
[HttpGet("{name}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
var item = _libraryManager.GetArtist(name, dtoOptions);
|
||||
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.Models.StreamingDtos;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The audio controller.
|
||||
/// </summary>
|
||||
public class AudioController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly AudioHelper _audioHelper;
|
||||
|
||||
private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AudioController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param>
|
||||
public AudioController(AudioHelper audioHelper)
|
||||
{
|
||||
_audioHelper = audioHelper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an audio stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="container">The audio container.</param>
|
||||
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
|
||||
/// <param name="params">The streaming parameters.</param>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The segment length.</param>
|
||||
/// <param name="minSegments">The minimum number of segments.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="audioCodec">Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.</param>
|
||||
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
|
||||
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
|
||||
/// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
|
||||
/// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
|
||||
/// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
|
||||
/// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
|
||||
/// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
|
||||
/// <param name="maxRefFrames">Optional.</param>
|
||||
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
|
||||
/// <param name="requireAvc">Optional. Whether to require avc.</param>
|
||||
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
|
||||
/// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
|
||||
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
|
||||
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
|
||||
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.</param>
|
||||
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
|
||||
/// <param name="transcodeReasons">Optional. The transcoding reason.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
|
||||
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
|
||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||
/// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
|
||||
/// <response code="200">Audio stream returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
|
||||
[HttpGet("{itemId}/stream", Name = "GetAudioStream")]
|
||||
[HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesAudioFile]
|
||||
public async Task<ActionResult> GetAudioStream(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
[FromQuery] int? audioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? width,
|
||||
[FromQuery] int? height,
|
||||
[FromQuery] int? videoBitRate,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] SubtitleDeliveryMethod? subtitleMethod,
|
||||
[FromQuery] int? maxRefFrames,
|
||||
[FromQuery] int? maxVideoBitDepth,
|
||||
[FromQuery] bool? requireAvc,
|
||||
[FromQuery] bool? deInterlace,
|
||||
[FromQuery] bool? requireNonAnamorphic,
|
||||
[FromQuery] int? transcodingMaxAudioChannels,
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
[FromQuery] EncodingContext? context,
|
||||
[FromQuery] Dictionary<string, string>? streamOptions,
|
||||
[FromQuery] bool enableAudioVbrEncoding = true)
|
||||
{
|
||||
StreamingRequestDto streamingRequest = new StreamingRequestDto
|
||||
{
|
||||
Id = itemId,
|
||||
Container = container,
|
||||
Static = @static ?? false,
|
||||
Params = @params,
|
||||
Tag = tag,
|
||||
PlaySessionId = playSessionId,
|
||||
SegmentContainer = segmentContainer,
|
||||
SegmentLength = segmentLength,
|
||||
MinSegments = minSegments,
|
||||
MediaSourceId = mediaSourceId,
|
||||
DeviceId = deviceId,
|
||||
AudioCodec = audioCodec,
|
||||
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
|
||||
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
|
||||
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
|
||||
AudioSampleRate = audioSampleRate,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
AudioBitRate = audioBitRate,
|
||||
MaxAudioBitDepth = maxAudioBitDepth,
|
||||
AudioChannels = audioChannels,
|
||||
Profile = profile,
|
||||
Level = level,
|
||||
Framerate = framerate,
|
||||
MaxFramerate = maxFramerate,
|
||||
CopyTimestamps = copyTimestamps ?? false,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
Width = width,
|
||||
Height = height,
|
||||
VideoBitRate = videoBitRate,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
|
||||
MaxRefFrames = maxRefFrames,
|
||||
MaxVideoBitDepth = maxVideoBitDepth,
|
||||
RequireAvc = requireAvc ?? false,
|
||||
DeInterlace = deInterlace ?? false,
|
||||
RequireNonAnamorphic = requireNonAnamorphic ?? false,
|
||||
TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
|
||||
CpuCoreLimit = cpuCoreLimit,
|
||||
LiveStreamId = liveStreamId,
|
||||
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
|
||||
VideoCodec = videoCodec,
|
||||
SubtitleCodec = subtitleCodec,
|
||||
TranscodeReasons = transcodeReasons,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
VideoStreamIndex = videoStreamIndex,
|
||||
Context = context ?? EncodingContext.Static,
|
||||
StreamOptions = streamOptions,
|
||||
EnableAudioVbrEncoding = enableAudioVbrEncoding
|
||||
};
|
||||
|
||||
return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an audio stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="container">The audio container.</param>
|
||||
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
|
||||
/// <param name="params">The streaming parameters.</param>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The segment length.</param>
|
||||
/// <param name="minSegments">The minimum number of segments.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="audioCodec">Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.</param>
|
||||
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
|
||||
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
|
||||
/// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
|
||||
/// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
|
||||
/// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
|
||||
/// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
|
||||
/// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
|
||||
/// <param name="maxRefFrames">Optional.</param>
|
||||
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
|
||||
/// <param name="requireAvc">Optional. Whether to require avc.</param>
|
||||
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
|
||||
/// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
|
||||
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
|
||||
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
|
||||
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.</param>
|
||||
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
|
||||
/// <param name="transcodeReasons">Optional. The transcoding reason.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
|
||||
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
|
||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||
/// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
|
||||
/// <response code="200">Audio stream returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
|
||||
[HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")]
|
||||
[HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesAudioFile]
|
||||
public async Task<ActionResult> GetAudioStreamByContainer(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
[FromQuery] int? audioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? width,
|
||||
[FromQuery] int? height,
|
||||
[FromQuery] int? videoBitRate,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] SubtitleDeliveryMethod? subtitleMethod,
|
||||
[FromQuery] int? maxRefFrames,
|
||||
[FromQuery] int? maxVideoBitDepth,
|
||||
[FromQuery] bool? requireAvc,
|
||||
[FromQuery] bool? deInterlace,
|
||||
[FromQuery] bool? requireNonAnamorphic,
|
||||
[FromQuery] int? transcodingMaxAudioChannels,
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
[FromQuery] EncodingContext? context,
|
||||
[FromQuery] Dictionary<string, string>? streamOptions,
|
||||
[FromQuery] bool enableAudioVbrEncoding = true)
|
||||
{
|
||||
StreamingRequestDto streamingRequest = new StreamingRequestDto
|
||||
{
|
||||
Id = itemId,
|
||||
Container = container,
|
||||
Static = @static ?? false,
|
||||
Params = @params,
|
||||
Tag = tag,
|
||||
PlaySessionId = playSessionId,
|
||||
SegmentContainer = segmentContainer,
|
||||
SegmentLength = segmentLength,
|
||||
MinSegments = minSegments,
|
||||
MediaSourceId = mediaSourceId,
|
||||
DeviceId = deviceId,
|
||||
AudioCodec = audioCodec,
|
||||
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
|
||||
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
|
||||
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
|
||||
AudioSampleRate = audioSampleRate,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
AudioBitRate = audioBitRate,
|
||||
MaxAudioBitDepth = maxAudioBitDepth,
|
||||
AudioChannels = audioChannels,
|
||||
Profile = profile,
|
||||
Level = level,
|
||||
Framerate = framerate,
|
||||
MaxFramerate = maxFramerate,
|
||||
CopyTimestamps = copyTimestamps ?? false,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
Width = width,
|
||||
Height = height,
|
||||
VideoBitRate = videoBitRate,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
|
||||
MaxRefFrames = maxRefFrames,
|
||||
MaxVideoBitDepth = maxVideoBitDepth,
|
||||
RequireAvc = requireAvc ?? false,
|
||||
DeInterlace = deInterlace ?? false,
|
||||
RequireNonAnamorphic = requireNonAnamorphic ?? false,
|
||||
TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
|
||||
CpuCoreLimit = cpuCoreLimit,
|
||||
LiveStreamId = liveStreamId,
|
||||
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
|
||||
VideoCodec = videoCodec,
|
||||
SubtitleCodec = subtitleCodec,
|
||||
TranscodeReasons = transcodeReasons,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
VideoStreamIndex = videoStreamIndex,
|
||||
Context = context ?? EncodingContext.Static,
|
||||
StreamOptions = streamOptions,
|
||||
EnableAudioVbrEncoding = enableAudioVbrEncoding
|
||||
};
|
||||
|
||||
return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.SystemBackupService;
|
||||
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The backup controller.
|
||||
/// </summary>
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public class BackupController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IBackupService _backupService;
|
||||
private readonly IApplicationPaths _applicationPaths;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BackupController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="backupService">Instance of the <see cref="IBackupService"/> interface.</param>
|
||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
public BackupController(IBackupService backupService, IApplicationPaths applicationPaths)
|
||||
{
|
||||
_backupService = backupService;
|
||||
_applicationPaths = applicationPaths;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Backup.
|
||||
/// </summary>
|
||||
/// <param name="backupOptions">The backup options.</param>
|
||||
/// <response code="200">Backup created.</response>
|
||||
/// <response code="403">User does not have permission to retrieve information.</response>
|
||||
/// <returns>The created backup manifest.</returns>
|
||||
[HttpPost("Create")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<BackupManifestDto>> CreateBackup([FromBody] BackupOptionsDto backupOptions)
|
||||
{
|
||||
return Ok(await _backupService.CreateBackupAsync(backupOptions ?? new()).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores to a backup by restarting the server and applying the backup.
|
||||
/// </summary>
|
||||
/// <param name="archiveRestoreDto">The data to start a restore process.</param>
|
||||
/// <response code="204">Backup restore started.</response>
|
||||
/// <response code="403">User does not have permission to retrieve information.</response>
|
||||
/// <returns>No-Content.</returns>
|
||||
[HttpPost("Restore")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult StartRestoreBackup([FromBody, BindRequired] BackupRestoreRequestDto archiveRestoreDto)
|
||||
{
|
||||
var archivePath = SanitizePath(archiveRestoreDto.ArchiveFileName);
|
||||
if (!System.IO.File.Exists(archivePath))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_backupService.ScheduleRestoreAndRestartServer(archivePath);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of all currently present backups in the backup directory.
|
||||
/// </summary>
|
||||
/// <response code="200">Backups available.</response>
|
||||
/// <response code="403">User does not have permission to retrieve information.</response>
|
||||
/// <returns>The list of backups.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<BackupManifestDto[]>> ListBackups()
|
||||
{
|
||||
return Ok(await _backupService.EnumerateBackups().ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the descriptor from an existing archive is present.
|
||||
/// </summary>
|
||||
/// <param name="path">The data to start a restore process.</param>
|
||||
/// <response code="200">Backup archive manifest.</response>
|
||||
/// <response code="204">Not a valid jellyfin Archive.</response>
|
||||
/// <response code="404">Not a valid path.</response>
|
||||
/// <response code="403">User does not have permission to retrieve information.</response>
|
||||
/// <returns>The backup manifest.</returns>
|
||||
[HttpGet("Manifest")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<BackupManifestDto>> GetBackup([BindRequired] string path)
|
||||
{
|
||||
var backupPath = SanitizePath(path);
|
||||
|
||||
if (!System.IO.File.Exists(backupPath))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var manifest = await _backupService.GetBackupManifest(backupPath).ConfigureAwait(false);
|
||||
if (manifest is null)
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
return Ok(manifest);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
private string SanitizePath(string path)
|
||||
{
|
||||
// sanitize path
|
||||
var archiveRestorePath = Path.GetFileName(Path.GetFullPath(path));
|
||||
var archivePath = Path.Combine(_applicationPaths.BackupPath, archiveRestorePath);
|
||||
return archivePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Model.Branding;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Branding controller.
|
||||
/// </summary>
|
||||
public class BrandingController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BrandingController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
public BrandingController(IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets branding configuration.
|
||||
/// </summary>
|
||||
/// <response code="200">Branding configuration returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns>
|
||||
[HttpGet("Configuration")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<BrandingOptionsDto> GetBrandingOptions()
|
||||
{
|
||||
var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
|
||||
|
||||
var brandingOptionsDto = new BrandingOptionsDto
|
||||
{
|
||||
LoginDisclaimer = brandingOptions.LoginDisclaimer,
|
||||
CustomCss = brandingOptions.CustomCss,
|
||||
SplashscreenEnabled = brandingOptions.SplashscreenEnabled
|
||||
};
|
||||
|
||||
return brandingOptionsDto;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets branding css.
|
||||
/// </summary>
|
||||
/// <response code="200">Branding css returned.</response>
|
||||
/// <response code="204">No branding css configured.</response>
|
||||
/// <returns>
|
||||
/// An <see cref="OkResult"/> containing the branding css if exist,
|
||||
/// or a <see cref="NoContentResult"/> if the css is not configured.
|
||||
/// </returns>
|
||||
[HttpGet("Css")]
|
||||
[HttpGet("Css.css", Name = "GetBrandingCss_2")]
|
||||
[Produces("text/css")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult<string> GetBrandingCss()
|
||||
{
|
||||
var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
|
||||
return options.CustomCss ?? string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Channels;
|
||||
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>
|
||||
/// Channels Controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class ChannelsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IChannelManager _channelManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChannelsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
public ChannelsController(IChannelManager channelManager, IUserManager userManager)
|
||||
{
|
||||
_channelManager = channelManager;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets available channels.
|
||||
/// </summary>
|
||||
/// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</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="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param>
|
||||
/// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param>
|
||||
/// <param name="isFavorite">Optional. Filter by channels that are favorite.</param>
|
||||
/// <response code="200">Channels returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the channels.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannels(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] bool? supportsLatestItems,
|
||||
[FromQuery] bool? supportsMediaDeletion,
|
||||
[FromQuery] bool? isFavorite)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
return await _channelManager.GetChannelsAsync(new ChannelQuery
|
||||
{
|
||||
Limit = limit,
|
||||
StartIndex = startIndex,
|
||||
UserId = userId.Value,
|
||||
SupportsLatestItems = supportsLatestItems,
|
||||
SupportsMediaDeletion = supportsMediaDeletion,
|
||||
IsFavorite = isFavorite
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all channel features.
|
||||
/// </summary>
|
||||
/// <response code="200">All channel features returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
|
||||
[HttpGet("Features")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures()
|
||||
{
|
||||
return _channelManager.GetAllChannelFeatures();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get channel features.
|
||||
/// </summary>
|
||||
/// <param name="channelId">Channel id.</param>
|
||||
/// <response code="200">Channel features returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
|
||||
[HttpGet("{channelId}/Features")]
|
||||
public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId)
|
||||
{
|
||||
return _channelManager.GetChannelFeatures(channelId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get channel items.
|
||||
/// </summary>
|
||||
/// <param name="channelId">Channel Id.</param>
|
||||
/// <param name="folderId">Optional. Folder Id.</param>
|
||||
/// <param name="userId">Optional. User Id.</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="sortOrder">Optional. Sort Order - Ascending,Descending.</param>
|
||||
/// <param name="filters">Optional. Specify additional filters to apply.</param>
|
||||
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <response code="200">Channel items returned.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task"/> representing the request to get the channel items.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the channel items.
|
||||
/// </returns>
|
||||
[HttpGet("{channelId}/Items")]
|
||||
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems(
|
||||
[FromRoute, Required] Guid channelId,
|
||||
[FromQuery] Guid? folderId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
Limit = limit,
|
||||
StartIndex = startIndex,
|
||||
ChannelIds = new[] { channelId },
|
||||
ParentId = folderId ?? Guid.Empty,
|
||||
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
|
||||
DtoOptions = new DtoOptions { Fields = fields }
|
||||
};
|
||||
|
||||
foreach (var filter in filters)
|
||||
{
|
||||
switch (filter)
|
||||
{
|
||||
case ItemFilter.IsFolder:
|
||||
query.IsFolder = true;
|
||||
break;
|
||||
case ItemFilter.IsNotFolder:
|
||||
query.IsFolder = false;
|
||||
break;
|
||||
case ItemFilter.IsUnplayed:
|
||||
query.IsPlayed = false;
|
||||
break;
|
||||
case ItemFilter.IsPlayed:
|
||||
query.IsPlayed = true;
|
||||
break;
|
||||
case ItemFilter.IsFavorite:
|
||||
query.IsFavorite = true;
|
||||
break;
|
||||
case ItemFilter.IsResumable:
|
||||
query.IsResumable = true;
|
||||
break;
|
||||
case ItemFilter.Likes:
|
||||
query.IsLiked = true;
|
||||
break;
|
||||
case ItemFilter.Dislikes:
|
||||
query.IsLiked = false;
|
||||
break;
|
||||
case ItemFilter.IsFavoriteOrLikes:
|
||||
query.IsFavoriteOrLiked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets latest channel items.
|
||||
/// </summary>
|
||||
/// <param name="userId">Optional. User Id.</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="filters">Optional. Specify additional filters to apply.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param>
|
||||
/// <response code="200">Latest channel items returned.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task"/> representing the request to get the latest channel items.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the latest channel items.
|
||||
/// </returns>
|
||||
[HttpGet("Items/Latest")]
|
||||
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] channelIds)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
Limit = limit,
|
||||
StartIndex = startIndex,
|
||||
ChannelIds = channelIds,
|
||||
DtoOptions = new DtoOptions { Fields = fields }
|
||||
};
|
||||
|
||||
foreach (var filter in filters)
|
||||
{
|
||||
switch (filter)
|
||||
{
|
||||
case ItemFilter.IsFolder:
|
||||
query.IsFolder = true;
|
||||
break;
|
||||
case ItemFilter.IsNotFolder:
|
||||
query.IsFolder = false;
|
||||
break;
|
||||
case ItemFilter.IsUnplayed:
|
||||
query.IsPlayed = false;
|
||||
break;
|
||||
case ItemFilter.IsPlayed:
|
||||
query.IsPlayed = true;
|
||||
break;
|
||||
case ItemFilter.IsFavorite:
|
||||
query.IsFavorite = true;
|
||||
break;
|
||||
case ItemFilter.IsResumable:
|
||||
query.IsResumable = true;
|
||||
break;
|
||||
case ItemFilter.Likes:
|
||||
query.IsLiked = true;
|
||||
break;
|
||||
case ItemFilter.Dislikes:
|
||||
query.IsLiked = false;
|
||||
break;
|
||||
case ItemFilter.IsFavoriteOrLikes:
|
||||
query.IsFavoriteOrLiked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Net.Mime;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Models.ClientLogDtos;
|
||||
using MediaBrowser.Controller.ClientEvent;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Client log controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class ClientLogController : BaseJellyfinApiController
|
||||
{
|
||||
private const int MaxDocumentSize = 1_000_000;
|
||||
private readonly IClientEventLogger _clientEventLogger;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ClientLogController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="clientEventLogger">Instance of the <see cref="IClientEventLogger"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
public ClientLogController(
|
||||
IClientEventLogger clientEventLogger,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_clientEventLogger = clientEventLogger;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload a document.
|
||||
/// </summary>
|
||||
/// <response code="200">Document saved.</response>
|
||||
/// <response code="403">Event logging disabled.</response>
|
||||
/// <response code="413">Upload size too large.</response>
|
||||
/// <returns>Create response.</returns>
|
||||
[HttpPost("Document")]
|
||||
[ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||
[AcceptsFile(MediaTypeNames.Text.Plain)]
|
||||
[RequestSizeLimit(MaxDocumentSize)]
|
||||
public async Task<ActionResult<ClientLogDocumentResponseDto>> LogFile()
|
||||
{
|
||||
if (!_serverConfigurationManager.Configuration.AllowClientLogUpload)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
if (Request.ContentLength > MaxDocumentSize)
|
||||
{
|
||||
// Manually validate to return proper status code.
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes");
|
||||
}
|
||||
|
||||
var (clientName, clientVersion) = GetRequestInformation();
|
||||
var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(new ClientLogDocumentResponseDto(fileName));
|
||||
}
|
||||
|
||||
private (string ClientName, string ClientVersion) GetRequestInformation()
|
||||
{
|
||||
var clientName = HttpContext.User.GetClient() ?? "unknown-client";
|
||||
var clientVersion = HttpContext.User.GetIsApiKey()
|
||||
? "apikey"
|
||||
: HttpContext.User.GetVersion() ?? "unknown-version";
|
||||
|
||||
return (clientName, clientVersion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Collections;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Model.Collections;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The collection controller.
|
||||
/// </summary>
|
||||
[Route("Collections")]
|
||||
[Authorize(Policy = Policies.CollectionManagement)]
|
||||
public class CollectionController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ICollectionManager _collectionManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CollectionController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
|
||||
public CollectionController(
|
||||
ICollectionManager collectionManager,
|
||||
IDtoService dtoService)
|
||||
{
|
||||
_collectionManager = collectionManager;
|
||||
_dtoService = dtoService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new collection.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the collection.</param>
|
||||
/// <param name="ids">Item Ids to add to the collection.</param>
|
||||
/// <param name="parentId">Optional. Create the collection within a specific folder.</param>
|
||||
/// <param name="isLocked">Whether or not to lock the new collection.</param>
|
||||
/// <response code="200">Collection created.</response>
|
||||
/// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
|
||||
[FromQuery] string? name,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] ids,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery] bool isLocked = false)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions
|
||||
{
|
||||
IsLocked = isLocked,
|
||||
Name = name,
|
||||
ParentId = parentId,
|
||||
ItemIdList = ids,
|
||||
UserIds = new[] { userId }
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
var dto = _dtoService.GetBaseItemDto(item, dtoOptions);
|
||||
|
||||
return new CollectionCreationResult
|
||||
{
|
||||
Id = dto.Id
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds items to a collection.
|
||||
/// </summary>
|
||||
/// <param name="collectionId">The collection id.</param>
|
||||
/// <param name="ids">Item ids, comma delimited.</param>
|
||||
/// <response code="204">Items added to collection.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("{collectionId}/Items")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> AddToCollection(
|
||||
[FromRoute, Required] Guid collectionId,
|
||||
[FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids)
|
||||
{
|
||||
await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes items from a collection.
|
||||
/// </summary>
|
||||
/// <param name="collectionId">The collection id.</param>
|
||||
/// <param name="ids">Item ids, comma delimited.</param>
|
||||
/// <response code="204">Items removed from collection.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpDelete("{collectionId}/Items")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> RemoveFromCollection(
|
||||
[FromRoute, Required] Guid collectionId,
|
||||
[FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids)
|
||||
{
|
||||
await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Mime;
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Branding;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration Controller.
|
||||
/// </summary>
|
||||
[Route("System")]
|
||||
[Authorize]
|
||||
public class ConfigurationController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IServerConfigurationManager _configurationManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
|
||||
private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigurationController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
public ConfigurationController(
|
||||
IServerConfigurationManager configurationManager,
|
||||
IMediaEncoder mediaEncoder)
|
||||
{
|
||||
_configurationManager = configurationManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets application configuration.
|
||||
/// </summary>
|
||||
/// <response code="200">Application configuration returned.</response>
|
||||
/// <returns>Application configuration.</returns>
|
||||
[HttpGet("Configuration")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<ServerConfiguration> GetConfiguration()
|
||||
{
|
||||
return _configurationManager.Configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates application configuration.
|
||||
/// </summary>
|
||||
/// <param name="configuration">Configuration.</param>
|
||||
/// <response code="204">Configuration updated.</response>
|
||||
/// <returns>Update status.</returns>
|
||||
[HttpPost("Configuration")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration)
|
||||
{
|
||||
_configurationManager.ReplaceConfiguration(configuration);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a named configuration.
|
||||
/// </summary>
|
||||
/// <param name="key">Configuration key.</param>
|
||||
/// <response code="200">Configuration returned.</response>
|
||||
/// <returns>Configuration.</returns>
|
||||
[HttpGet("Configuration/{key}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesFile(MediaTypeNames.Application.Json)]
|
||||
public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string key)
|
||||
{
|
||||
return _configurationManager.GetConfiguration(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates named configuration.
|
||||
/// </summary>
|
||||
/// <param name="key">Configuration key.</param>
|
||||
/// <param name="configuration">Configuration.</param>
|
||||
/// <response code="204">Named configuration updated.</response>
|
||||
/// <returns>Update status.</returns>
|
||||
[HttpPost("Configuration/{key}")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult UpdateNamedConfiguration([FromRoute, Required] string key, [FromBody, Required] JsonDocument configuration)
|
||||
{
|
||||
var configurationType = _configurationManager.GetConfigurationType(key);
|
||||
var deserializedConfiguration = configuration.Deserialize(configurationType, _serializerOptions);
|
||||
|
||||
if (deserializedConfiguration is null)
|
||||
{
|
||||
throw new ArgumentException("Body doesn't contain a valid configuration");
|
||||
}
|
||||
|
||||
_configurationManager.SaveConfiguration(key, deserializedConfiguration);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a default MetadataOptions object.
|
||||
/// </summary>
|
||||
/// <response code="200">Metadata options returned.</response>
|
||||
/// <returns>Default MetadataOptions.</returns>
|
||||
[HttpGet("Configuration/MetadataOptions/Default")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<MetadataOptions> GetDefaultMetadataOptions()
|
||||
{
|
||||
return new MetadataOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates branding configuration.
|
||||
/// </summary>
|
||||
/// <param name="configuration">Branding configuration.</param>
|
||||
/// <response code="204">Branding configuration updated.</response>
|
||||
/// <returns>Update status.</returns>
|
||||
[HttpPost("Configuration/Branding")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult UpdateBrandingConfiguration([FromBody, Required] BrandingOptionsDto configuration)
|
||||
{
|
||||
// Get the current branding configuration to preserve SplashscreenLocation
|
||||
var currentBranding = (BrandingOptions)_configurationManager.GetConfiguration("branding");
|
||||
|
||||
// Update only the properties from BrandingOptionsDto
|
||||
currentBranding.LoginDisclaimer = configuration.LoginDisclaimer;
|
||||
currentBranding.CustomCss = configuration.CustomCss;
|
||||
currentBranding.SplashscreenEnabled = configuration.SplashscreenEnabled;
|
||||
|
||||
_configurationManager.SaveConfiguration("branding", currentBranding);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Models;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Net;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
public class DashboardController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILogger<DashboardController> _logger;
|
||||
private readonly IPluginManager _pluginManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DashboardController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param>
|
||||
/// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param>
|
||||
public DashboardController(
|
||||
ILogger<DashboardController> logger,
|
||||
IPluginManager pluginManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_pluginManager = pluginManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration pages.
|
||||
/// </summary>
|
||||
/// <param name="enableInMainMenu">Whether to enable in the main menu.</param>
|
||||
/// <response code="200">ConfigurationPages returned.</response>
|
||||
/// <response code="404">Server still loading.</response>
|
||||
/// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns>
|
||||
[HttpGet("web/ConfigurationPages")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages(
|
||||
[FromQuery] bool? enableInMainMenu)
|
||||
{
|
||||
var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList();
|
||||
|
||||
if (enableInMainMenu.HasValue)
|
||||
{
|
||||
configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList();
|
||||
}
|
||||
|
||||
return configPages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a dashboard configuration page.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the page.</param>
|
||||
/// <response code="200">ConfigurationPage returned.</response>
|
||||
/// <response code="404">Plugin configuration page not found.</response>
|
||||
/// <returns>The configuration page.</returns>
|
||||
[HttpGet("web/ConfigurationPage")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")]
|
||||
public ActionResult GetDashboardConfigurationPage([FromQuery] string? name)
|
||||
{
|
||||
var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (altPage is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
IPlugin plugin = altPage.Item2;
|
||||
string resourcePath = altPage.Item1.EmbeddedResourcePath;
|
||||
Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath);
|
||||
if (stream is null)
|
||||
{
|
||||
_logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name);
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return File(stream, MimeTypes.GetMimeType(resourcePath));
|
||||
}
|
||||
|
||||
private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin)
|
||||
{
|
||||
return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1));
|
||||
}
|
||||
|
||||
private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin plugin)
|
||||
{
|
||||
if (plugin.Instance is not IHasWebPages hasWebPages)
|
||||
{
|
||||
return Enumerable.Empty<Tuple<PluginPageInfo, IPlugin>>();
|
||||
}
|
||||
|
||||
return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance));
|
||||
}
|
||||
|
||||
private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages()
|
||||
{
|
||||
return _pluginManager.Plugins.SelectMany(GetPluginPages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Data.Dtos;
|
||||
using Jellyfin.Data.Queries;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Session;
|
||||
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>
|
||||
/// Devices Controller.
|
||||
/// </summary>
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public class DevicesController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DevicesController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
|
||||
/// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
|
||||
public DevicesController(
|
||||
IDeviceManager deviceManager,
|
||||
ISessionManager sessionManager)
|
||||
{
|
||||
_deviceManager = deviceManager;
|
||||
_sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get Devices.
|
||||
/// </summary>
|
||||
/// <param name="userId">Gets or sets the user identifier.</param>
|
||||
/// <response code="200">Devices retrieved.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<DeviceInfoDto>> GetDevices([FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
return _deviceManager.GetDevicesForUser(userId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get info for a device.
|
||||
/// </summary>
|
||||
/// <param name="id">Device Id.</param>
|
||||
/// <response code="200">Device info retrieved.</response>
|
||||
/// <response code="404">Device not found.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
|
||||
[HttpGet("Info")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<DeviceInfoDto> GetDeviceInfo([FromQuery, Required] string id)
|
||||
{
|
||||
var deviceInfo = _deviceManager.GetDevice(id);
|
||||
if (deviceInfo is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return deviceInfo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get options for a device.
|
||||
/// </summary>
|
||||
/// <param name="id">Device Id.</param>
|
||||
/// <response code="200">Device options retrieved.</response>
|
||||
/// <response code="404">Device not found.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
|
||||
[HttpGet("Options")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<DeviceOptionsDto> GetDeviceOptions([FromQuery, Required] string id)
|
||||
{
|
||||
var deviceInfo = _deviceManager.GetDeviceOptions(id);
|
||||
if (deviceInfo is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return deviceInfo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update device options.
|
||||
/// </summary>
|
||||
/// <param name="id">Device Id.</param>
|
||||
/// <param name="deviceOptions">Device Options.</param>
|
||||
/// <response code="204">Device options updated.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Options")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> UpdateDeviceOptions(
|
||||
[FromQuery, Required] string id,
|
||||
[FromBody, Required] DeviceOptionsDto deviceOptions)
|
||||
{
|
||||
await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a device.
|
||||
/// </summary>
|
||||
/// <param name="id">Device Id.</param>
|
||||
/// <response code="204">Device deleted.</response>
|
||||
/// <response code="404">Device not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
|
||||
[HttpDelete]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id)
|
||||
{
|
||||
var existingDevice = _deviceManager.GetDevice(id);
|
||||
if (existingDevice is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = id });
|
||||
|
||||
foreach (var session in sessions.Items)
|
||||
{
|
||||
await _sessionManager.Logout(session).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Display Preferences Controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class DisplayPreferencesController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IDisplayPreferencesManager _displayPreferencesManager;
|
||||
private readonly ILogger<DisplayPreferencesController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param>
|
||||
/// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param>
|
||||
public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger)
|
||||
{
|
||||
_displayPreferencesManager = displayPreferencesManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get Display Preferences.
|
||||
/// </summary>
|
||||
/// <param name="displayPreferencesId">Display preferences id.</param>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="client">Client.</param>
|
||||
/// <response code="200">Display preferences retrieved.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns>
|
||||
[HttpGet("{displayPreferencesId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
|
||||
public ActionResult<DisplayPreferencesDto> GetDisplayPreferences(
|
||||
[FromRoute, Required] string displayPreferencesId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery, Required] string client)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
|
||||
if (!Guid.TryParse(displayPreferencesId, out var itemId))
|
||||
{
|
||||
itemId = displayPreferencesId.GetMD5();
|
||||
}
|
||||
|
||||
var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId.Value, itemId, client);
|
||||
var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
|
||||
itemPreferences.ItemId = itemId;
|
||||
|
||||
var dto = new DisplayPreferencesDto
|
||||
{
|
||||
Client = displayPreferences.Client,
|
||||
Id = displayPreferences.ItemId.ToString(),
|
||||
SortBy = itemPreferences.SortBy,
|
||||
SortOrder = itemPreferences.SortOrder,
|
||||
IndexBy = displayPreferences.IndexBy?.ToString(),
|
||||
RememberIndexing = itemPreferences.RememberIndexing,
|
||||
RememberSorting = itemPreferences.RememberSorting,
|
||||
ScrollDirection = displayPreferences.ScrollDirection,
|
||||
ShowBackdrop = displayPreferences.ShowBackdrop,
|
||||
ShowSidebar = displayPreferences.ShowSidebar
|
||||
};
|
||||
|
||||
foreach (var homeSection in displayPreferences.HomeSections)
|
||||
{
|
||||
dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant();
|
||||
dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture);
|
||||
dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture);
|
||||
dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
|
||||
dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
|
||||
dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme;
|
||||
|
||||
// Load all custom display preferences
|
||||
var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
|
||||
foreach (var (key, value) in customDisplayPreferences)
|
||||
{
|
||||
dto.CustomPrefs.TryAdd(key, value);
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update Display Preferences.
|
||||
/// </summary>
|
||||
/// <param name="displayPreferencesId">Display preferences id.</param>
|
||||
/// <param name="userId">User Id.</param>
|
||||
/// <param name="client">Client.</param>
|
||||
/// <param name="displayPreferences">New Display Preferences object.</param>
|
||||
/// <response code="204">Display preferences updated.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
|
||||
[HttpPost("{displayPreferencesId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
|
||||
public ActionResult UpdateDisplayPreferences(
|
||||
[FromRoute, Required] string displayPreferencesId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery, Required] string client,
|
||||
[FromBody, Required] DisplayPreferencesDto displayPreferences)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
|
||||
HomeSectionType[] defaults =
|
||||
{
|
||||
HomeSectionType.SmallLibraryTiles,
|
||||
HomeSectionType.Resume,
|
||||
HomeSectionType.ResumeAudio,
|
||||
HomeSectionType.ResumeBook,
|
||||
HomeSectionType.LiveTv,
|
||||
HomeSectionType.NextUp,
|
||||
HomeSectionType.LatestMedia,
|
||||
HomeSectionType.None,
|
||||
};
|
||||
|
||||
if (!Guid.TryParse(displayPreferencesId, out var itemId))
|
||||
{
|
||||
itemId = displayPreferencesId.GetMD5();
|
||||
}
|
||||
|
||||
var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId.Value, itemId, client);
|
||||
existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null;
|
||||
existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop;
|
||||
existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar;
|
||||
|
||||
existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection;
|
||||
existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion)
|
||||
&& !string.IsNullOrEmpty(chromecastVersion)
|
||||
? Enum.Parse<ChromecastVersion>(chromecastVersion, true)
|
||||
: ChromecastVersion.Stable;
|
||||
displayPreferences.CustomPrefs.Remove("chromecastVersion");
|
||||
|
||||
existingDisplayPreferences.EnableNextVideoInfoOverlay = !displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay)
|
||||
|| string.IsNullOrEmpty(enableNextVideoInfoOverlay)
|
||||
|| bool.Parse(enableNextVideoInfoOverlay);
|
||||
displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay");
|
||||
|
||||
existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength)
|
||||
&& !string.IsNullOrEmpty(skipBackLength)
|
||||
? int.Parse(skipBackLength, CultureInfo.InvariantCulture)
|
||||
: 10000;
|
||||
displayPreferences.CustomPrefs.Remove("skipBackLength");
|
||||
|
||||
existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength)
|
||||
&& !string.IsNullOrEmpty(skipForwardLength)
|
||||
? int.Parse(skipForwardLength, CultureInfo.InvariantCulture)
|
||||
: 30000;
|
||||
displayPreferences.CustomPrefs.Remove("skipForwardLength");
|
||||
|
||||
existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme)
|
||||
? theme
|
||||
: string.Empty;
|
||||
displayPreferences.CustomPrefs.Remove("dashboardTheme");
|
||||
|
||||
existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home)
|
||||
? home
|
||||
: string.Empty;
|
||||
displayPreferences.CustomPrefs.Remove("tvhome");
|
||||
|
||||
existingDisplayPreferences.HomeSections.Clear();
|
||||
|
||||
foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var order = int.Parse(key.AsSpan().Slice("homesection".Length), CultureInfo.InvariantCulture);
|
||||
if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type))
|
||||
{
|
||||
type = order < 8 ? defaults[order] : HomeSectionType.None;
|
||||
}
|
||||
|
||||
displayPreferences.CustomPrefs.Remove(key);
|
||||
existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type });
|
||||
}
|
||||
|
||||
foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var viewType = displayPreferences.CustomPrefs[key];
|
||||
|
||||
if (string.IsNullOrEmpty(viewType))
|
||||
{
|
||||
displayPreferences.CustomPrefs.Remove(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<ViewType>(viewType, true, out _))
|
||||
{
|
||||
_logger.LogError("Invalid ViewType: {LandingScreenOption}", viewType);
|
||||
displayPreferences.CustomPrefs.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client);
|
||||
itemPrefs.SortBy = displayPreferences.SortBy ?? "SortName";
|
||||
itemPrefs.SortOrder = displayPreferences.SortOrder;
|
||||
itemPrefs.RememberIndexing = displayPreferences.RememberIndexing;
|
||||
itemPrefs.RememberSorting = displayPreferences.RememberSorting;
|
||||
itemPrefs.ItemId = itemId;
|
||||
|
||||
// Set all remaining custom preferences.
|
||||
_displayPreferencesManager.SetCustomItemDisplayPreferences(userId.Value, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
|
||||
_displayPreferencesManager.UpdateItemDisplayPreferences(itemPrefs);
|
||||
_displayPreferencesManager.UpdateDisplayPreferences(existingDisplayPreferences);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Models.EnvironmentDtos;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Environment Controller.
|
||||
/// </summary>
|
||||
[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
|
||||
public class EnvironmentController : BaseJellyfinApiController
|
||||
{
|
||||
private const char UncSeparator = '\\';
|
||||
private const string UncStartPrefix = @"\\";
|
||||
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<EnvironmentController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EnvironmentController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param>
|
||||
public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger)
|
||||
{
|
||||
_fileSystem = fileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the contents of a given directory in the file system.
|
||||
/// </summary>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param>
|
||||
/// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param>
|
||||
/// <response code="200">Directory contents returned.</response>
|
||||
/// <returns>Directory contents.</returns>
|
||||
[HttpGet("DirectoryContents")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IEnumerable<FileSystemEntryInfo> GetDirectoryContents(
|
||||
[FromQuery, Required] string path,
|
||||
[FromQuery] bool includeFiles = false,
|
||||
[FromQuery] bool includeDirectories = false)
|
||||
{
|
||||
if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& path.LastIndexOf(UncSeparator) == 1)
|
||||
{
|
||||
return Array.Empty<FileSystemEntryInfo>();
|
||||
}
|
||||
|
||||
var entries =
|
||||
_fileSystem.GetFileSystemEntries(path)
|
||||
.Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles))
|
||||
.OrderBy(i => i.FullName);
|
||||
|
||||
return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates path.
|
||||
/// </summary>
|
||||
/// <param name="validatePathDto">Validate request object.</param>
|
||||
/// <response code="204">Path validated.</response>
|
||||
/// <response code="404">Path not found.</response>
|
||||
/// <returns>Validation status.</returns>
|
||||
[HttpPost("ValidatePath")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto)
|
||||
{
|
||||
if (validatePathDto.IsFile.HasValue)
|
||||
{
|
||||
if (validatePathDto.IsFile.Value)
|
||||
{
|
||||
if (!System.IO.File.Exists(validatePathDto.Path))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!Directory.Exists(validatePathDto.Path))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (validatePathDto.ValidateWritable)
|
||||
{
|
||||
if (validatePathDto.Path is null)
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(validatePathDto.Path));
|
||||
}
|
||||
|
||||
var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString());
|
||||
try
|
||||
{
|
||||
System.IO.File.WriteAllText(file, string.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (System.IO.File.Exists(file))
|
||||
{
|
||||
System.IO.File.Delete(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets available drives from the server's file system.
|
||||
/// </summary>
|
||||
/// <response code="200">List of entries returned.</response>
|
||||
/// <returns>List of entries.</returns>
|
||||
[HttpGet("Drives")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IEnumerable<FileSystemEntryInfo> GetDrives()
|
||||
{
|
||||
return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent path of a given path.
|
||||
/// </summary>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <returns>Parent path.</returns>
|
||||
[HttpGet("ParentPath")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<string?> GetParentPath([FromQuery, Required] string path)
|
||||
{
|
||||
string? parent = Path.GetDirectoryName(path);
|
||||
if (string.IsNullOrEmpty(parent))
|
||||
{
|
||||
// Check if unc share
|
||||
var index = path.LastIndexOf(UncSeparator);
|
||||
|
||||
if (index != -1 && path[0] == UncSeparator)
|
||||
{
|
||||
parent = path.Substring(0, index);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator)))
|
||||
{
|
||||
parent = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get Default directory browser.
|
||||
/// </summary>
|
||||
/// <response code="200">Default directory browser returned.</response>
|
||||
/// <returns>Default directory browser.</returns>
|
||||
[HttpGet("DefaultDirectoryBrowser")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser()
|
||||
{
|
||||
return new DefaultDirectoryBrowserInfoDto();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
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>
|
||||
[Route("")]
|
||||
[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] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
BaseItem? item = null;
|
||||
if (includeItemTypes.Length != 1
|
||||
|| !(includeItemTypes[0] == BaseItemKind.BoxSet
|
||||
|| includeItemTypes[0] == BaseItemKind.Playlist
|
||||
|| includeItemTypes[0] == BaseItemKind.Trailer
|
||||
|| includeItemTypes[0] == BaseItemKind.Program))
|
||||
{
|
||||
item = _libraryManager.GetParentItem(parentId, user?.Id);
|
||||
}
|
||||
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
User = user,
|
||||
MediaTypes = mediaTypes,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
Recursive = true,
|
||||
EnableTotalRecordCount = false,
|
||||
DtoOptions = new DtoOptions
|
||||
{
|
||||
Fields = new[] { ItemFields.Genres, ItemFields.Tags },
|
||||
EnableImages = false,
|
||||
EnableUserData = false
|
||||
}
|
||||
};
|
||||
|
||||
if (item is not Folder folder)
|
||||
{
|
||||
return new QueryFiltersLegacy();
|
||||
}
|
||||
|
||||
var itemList = folder.GetItemList(query);
|
||||
return new QueryFiltersLegacy
|
||||
{
|
||||
Years = itemList.Select(i => i.ProductionYear ?? -1)
|
||||
.Where(i => i > 0)
|
||||
.Distinct()
|
||||
.Order()
|
||||
.ToArray(),
|
||||
|
||||
Genres = itemList.SelectMany(i => i.Genres)
|
||||
.DistinctNames()
|
||||
.Order()
|
||||
.ToArray(),
|
||||
|
||||
Tags = itemList
|
||||
.SelectMany(i => i.Tags)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Order()
|
||||
.ToArray(),
|
||||
|
||||
OfficialRatings = itemList
|
||||
.Select(i => i.OfficialRating)
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Order()
|
||||
.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="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] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery] bool? isAiring,
|
||||
[FromQuery] bool? isMovie,
|
||||
[FromQuery] bool? isSports,
|
||||
[FromQuery] bool? isKids,
|
||||
[FromQuery] bool? isNews,
|
||||
[FromQuery] bool? isSeries,
|
||||
[FromQuery] bool? recursive)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
BaseItem? parentItem = null;
|
||||
if (includeItemTypes.Length == 1
|
||||
&& (includeItemTypes[0] == BaseItemKind.BoxSet
|
||||
|| includeItemTypes[0] == BaseItemKind.Playlist
|
||||
|| includeItemTypes[0] == BaseItemKind.Trailer
|
||||
|| includeItemTypes[0] == BaseItemKind.Program))
|
||||
{
|
||||
parentItem = null;
|
||||
}
|
||||
else if (parentId.HasValue)
|
||||
{
|
||||
parentItem = _libraryManager.GetItemById<BaseItem>(parentId.Value);
|
||||
}
|
||||
|
||||
var filters = new QueryFilters();
|
||||
var genreQuery = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
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 is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
|
||||
}
|
||||
else
|
||||
{
|
||||
genreQuery.Parent = parentItem;
|
||||
}
|
||||
|
||||
if (includeItemTypes.Length == 1
|
||||
&& (includeItemTypes[0] == BaseItemKind.MusicAlbum
|
||||
|| includeItemTypes[0] == BaseItemKind.MusicVideo
|
||||
|| includeItemTypes[0] == BaseItemKind.MusicArtist
|
||||
|| includeItemTypes[0] == BaseItemKind.Audio))
|
||||
{
|
||||
filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
|
||||
{
|
||||
Name = i.Item.Name,
|
||||
Id = i.Item.Id
|
||||
}).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair
|
||||
{
|
||||
Name = i.Item.Name,
|
||||
Id = i.Item.Id
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Genre = MediaBrowser.Controller.Entities.Genre;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The genres controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class GenresController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GenresController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
public GenresController(
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
_dtoService = dtoService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all genres from a given item, folder, or the entire library.
|
||||
/// </summary>
|
||||
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="searchTerm">The search term.</param>
|
||||
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
|
||||
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
|
||||
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
|
||||
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
|
||||
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
|
||||
/// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
|
||||
/// <param name="enableImages">Optional, include image information in output.</param>
|
||||
/// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
|
||||
/// <response code="200">Genres returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetGenres(
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? searchTerm,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery] bool? isFavorite,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? nameStartsWithOrGreater,
|
||||
[FromQuery] string? nameStartsWith,
|
||||
[FromQuery] string? nameLessThan,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
|
||||
[FromQuery] bool? enableImages = true,
|
||||
[FromQuery] bool enableTotalRecordCount = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
var parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
StartIndex = startIndex,
|
||||
Limit = limit,
|
||||
IsFavorite = isFavorite,
|
||||
NameLessThan = nameLessThan,
|
||||
NameStartsWith = nameStartsWith,
|
||||
NameStartsWithOrGreater = nameStartsWithOrGreater,
|
||||
DtoOptions = dtoOptions,
|
||||
SearchTerm = searchTerm,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
|
||||
};
|
||||
|
||||
if (parentId.HasValue)
|
||||
{
|
||||
if (parentItem is Folder)
|
||||
{
|
||||
query.AncestorIds = new[] { parentId.Value };
|
||||
}
|
||||
else
|
||||
{
|
||||
query.ItemIds = new[] { parentId.Value };
|
||||
}
|
||||
}
|
||||
|
||||
QueryResult<(BaseItem, ItemCounts)> result;
|
||||
if (parentItem is ICollectionFolder parentCollectionFolder
|
||||
&& (parentCollectionFolder.CollectionType == CollectionType.music
|
||||
|| parentCollectionFolder.CollectionType == CollectionType.musicvideos))
|
||||
{
|
||||
result = _libraryManager.GetMusicGenres(query);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = _libraryManager.GetGenres(query);
|
||||
}
|
||||
|
||||
var shouldIncludeItemTypes = includeItemTypes.Length != 0;
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a genre, by name.
|
||||
/// </summary>
|
||||
/// <param name="genreName">The genre name.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <response code="200">Genres returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the genre.</returns>
|
||||
[HttpGet("{genreName}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
Genre? item;
|
||||
if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre);
|
||||
}
|
||||
else
|
||||
{
|
||||
item = _libraryManager.GetGenre(genreName);
|
||||
}
|
||||
|
||||
item ??= new Genre();
|
||||
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
|
||||
private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind)
|
||||
where T : BaseItem, new()
|
||||
{
|
||||
var result = libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
Name = name.Replace(BaseItem.SlugChar, '&'),
|
||||
IncludeItemTypes = new[] { baseItemKind },
|
||||
DtoOptions = dtoOptions
|
||||
}).OfType<T>().FirstOrDefault();
|
||||
|
||||
result ??= libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
Name = name.Replace(BaseItem.SlugChar, '/'),
|
||||
IncludeItemTypes = new[] { baseItemKind },
|
||||
DtoOptions = dtoOptions
|
||||
}).OfType<T>().FirstOrDefault();
|
||||
|
||||
result ??= libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
Name = name.Replace(BaseItem.SlugChar, '?'),
|
||||
IncludeItemTypes = new[] { baseItemKind },
|
||||
DtoOptions = dtoOptions
|
||||
}).OfType<T>().FirstOrDefault();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
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>
|
||||
/// The hls segment controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
public class HlsSegmentController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly ITranscodeManager _transcodeManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HlsSegmentController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
|
||||
public HlsSegmentController(
|
||||
IFileSystem fileSystem,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
ITranscodeManager transcodeManager)
|
||||
{
|
||||
_fileSystem = fileSystem;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_transcodeManager = transcodeManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the specified audio segment for an audio item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="segmentId">The segment id.</param>
|
||||
/// <response code="200">Hls audio segment returned.</response>
|
||||
/// <returns>A <see cref="FileStreamResult"/> containing the audio stream.</returns>
|
||||
// Can't require authentication just yet due to seeing some requests come from Chrome without full query string
|
||||
// [Authenticated]
|
||||
[HttpGet("Audio/{itemId}/hls/{segmentId}/stream.mp3", Name = "GetHlsAudioSegmentLegacyMp3")]
|
||||
[HttpGet("Audio/{itemId}/hls/{segmentId}/stream.aac", Name = "GetHlsAudioSegmentLegacyAac")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesAudioFile]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
|
||||
public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
|
||||
{
|
||||
// TODO: Deprecate with new iOS app
|
||||
var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
|
||||
var transcodePath = _serverConfigurationManager.GetTranscodePath();
|
||||
file = Path.GetFullPath(Path.Combine(transcodePath, file));
|
||||
var fileDir = Path.GetDirectoryName(file);
|
||||
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
|
||||
{
|
||||
return BadRequest("Invalid segment.");
|
||||
}
|
||||
|
||||
return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a hls video playlist.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The video id.</param>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <response code="200">Hls video playlist returned.</response>
|
||||
/// <returns>A <see cref="FileStreamResult"/> containing the playlist.</returns>
|
||||
[HttpGet("Videos/{itemId}/hls/{playlistId}/stream.m3u8")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesPlaylistFile]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
|
||||
public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
|
||||
{
|
||||
var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()));
|
||||
var transcodePath = _serverConfigurationManager.GetTranscodePath();
|
||||
file = Path.GetFullPath(Path.Combine(transcodePath, file));
|
||||
var fileDir = Path.GetDirectoryName(file);
|
||||
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)
|
||||
|| Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest("Invalid segment.");
|
||||
}
|
||||
|
||||
return GetFileResult(file, file);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops an active encoding.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <response code="204">Encoding stopped successfully.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpDelete("Videos/ActiveEncodings")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult StopEncodingProcess(
|
||||
[FromQuery, Required] string deviceId,
|
||||
[FromQuery, Required] string playSessionId)
|
||||
{
|
||||
_transcodeManager.KillTranscodingJobs(deviceId, playSessionId, _ => true);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a hls video segment.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <param name="segmentId">The segment id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <response code="200">Hls video segment returned.</response>
|
||||
/// <response code="404">Hls segment not found.</response>
|
||||
/// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns>
|
||||
// Can't require authentication just yet due to seeing some requests come from Chrome without full query string
|
||||
// [Authenticated]
|
||||
[HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesVideoFile]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
|
||||
public ActionResult GetHlsVideoSegmentLegacy(
|
||||
[FromRoute, Required] string itemId,
|
||||
[FromRoute, Required] string playlistId,
|
||||
[FromRoute, Required] string segmentId,
|
||||
[FromRoute, Required] string segmentContainer)
|
||||
{
|
||||
var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
|
||||
var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
|
||||
|
||||
file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
|
||||
var fileDir = Path.GetDirectoryName(file);
|
||||
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
|
||||
{
|
||||
return BadRequest("Invalid segment.");
|
||||
}
|
||||
|
||||
var normalizedPlaylistId = playlistId;
|
||||
|
||||
var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
|
||||
// Add . to start of segment container for future use.
|
||||
segmentContainer = segmentContainer.Insert(0, ".");
|
||||
string? playlistPath = null;
|
||||
foreach (var path in filePaths)
|
||||
{
|
||||
var pathExtension = Path.GetExtension(path);
|
||||
if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
&& path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
playlistPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return playlistPath is null
|
||||
? NotFound("Hls segment not found.")
|
||||
: GetFileResult(file, playlistPath);
|
||||
}
|
||||
|
||||
private ActionResult GetFileResult(string path, string playlistPath)
|
||||
{
|
||||
var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
|
||||
|
||||
Response.OnCompleted(() =>
|
||||
{
|
||||
if (transcodingJob is not null)
|
||||
{
|
||||
_transcodeManager.OnTranscodeEndRequest(transcodingJob);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Playlists;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The instant mix controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize]
|
||||
public class InstantMixController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IMusicManager _musicManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InstantMixController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public InstantMixController(
|
||||
IUserManager userManager,
|
||||
IDtoService dtoService,
|
||||
IMusicManager musicManager,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_dtoService = dtoService;
|
||||
_musicManager = musicManager;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instant playlist based on a given song.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <response code="200">Instant playlist returned.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
|
||||
[HttpGet("Songs/{itemId}/InstantMix")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instant playlist based on a given album.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <response code="200">Instant playlist returned.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
|
||||
[HttpGet("Albums/{itemId}/InstantMix")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instant playlist based on a given playlist.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <response code="200">Instant playlist returned.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
|
||||
[HttpGet("Playlists/{itemId}/InstantMix")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var item = _libraryManager.GetItemById<Playlist>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instant playlist based on a given genre.
|
||||
/// </summary>
|
||||
/// <param name="name">The genre name.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <response code="200">Instant playlist returned.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
|
||||
[HttpGet("MusicGenres/{name}/InstantMix")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName(
|
||||
[FromRoute, Required] string name,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instant playlist based on a given artist.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <response code="200">Instant playlist returned.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
|
||||
[HttpGet("Artists/{itemId}/InstantMix")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instant playlist based on a given item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <response code="200">Instant playlist returned.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
|
||||
[HttpGet("Items/{itemId}/InstantMix")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instant playlist based on a given artist.
|
||||
/// </summary>
|
||||
/// <param name="id">The item id.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <response code="200">Instant playlist returned.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
|
||||
[HttpGet("Artists/InstantMix")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Obsolete("Use GetInstantMixFromArtists")]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2(
|
||||
[FromQuery, Required] Guid id,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
return GetInstantMixFromArtists(
|
||||
id,
|
||||
userId,
|
||||
limit,
|
||||
fields,
|
||||
enableImages,
|
||||
enableUserData,
|
||||
imageTypeLimit,
|
||||
enableImageTypes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an instant playlist based on a given genre.
|
||||
/// </summary>
|
||||
/// <param name="id">The item id.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <response code="200">Instant playlist returned.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
|
||||
[HttpGet("MusicGenres/InstantMix")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById(
|
||||
[FromQuery, Required] Guid id,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var item = _libraryManager.GetItemById<BaseItem>(id, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
}
|
||||
|
||||
private QueryResult<BaseItemDto> GetResult(IReadOnlyList<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions)
|
||||
{
|
||||
var totalCount = items.Count;
|
||||
|
||||
if (limit.HasValue && limit < items.Count)
|
||||
{
|
||||
items = items.Take(limit.Value).ToArray();
|
||||
}
|
||||
|
||||
var result = new QueryResult<BaseItemDto>(
|
||||
0,
|
||||
totalCount,
|
||||
_dtoService.GetBaseItemDtos(items, dtoOptions, user));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
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>
|
||||
/// Item lookup controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize]
|
||||
public class ItemLookupController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<ItemLookupController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ItemLookupController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param>
|
||||
public ItemLookupController(
|
||||
IProviderManager providerManager,
|
||||
IFileSystem fileSystem,
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<ItemLookupController> logger)
|
||||
{
|
||||
_providerManager = providerManager;
|
||||
_fileSystem = fileSystem;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the item's external id info.
|
||||
/// </summary>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">External id info retrieved.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>List of external id info.</returns>
|
||||
[HttpGet("Items/{itemId}/ExternalIdInfos")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(_providerManager.GetExternalIdInfos(item));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get movie remote search.
|
||||
/// </summary>
|
||||
/// <param name="query">Remote search query.</param>
|
||||
/// <response code="200">Movie remote search executed.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
|
||||
/// </returns>
|
||||
[HttpPost("Items/RemoteSearch/Movie")]
|
||||
public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MovieInfo> query)
|
||||
{
|
||||
var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get trailer remote search.
|
||||
/// </summary>
|
||||
/// <param name="query">Remote search query.</param>
|
||||
/// <response code="200">Trailer remote search executed.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
|
||||
/// </returns>
|
||||
[HttpPost("Items/RemoteSearch/Trailer")]
|
||||
public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery<TrailerInfo> query)
|
||||
{
|
||||
var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get music video remote search.
|
||||
/// </summary>
|
||||
/// <param name="query">Remote search query.</param>
|
||||
/// <response code="200">Music video remote search executed.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
|
||||
/// </returns>
|
||||
[HttpPost("Items/RemoteSearch/MusicVideo")]
|
||||
public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MusicVideoInfo> query)
|
||||
{
|
||||
var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get series remote search.
|
||||
/// </summary>
|
||||
/// <param name="query">Remote search query.</param>
|
||||
/// <response code="200">Series remote search executed.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
|
||||
/// </returns>
|
||||
[HttpPost("Items/RemoteSearch/Series")]
|
||||
public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery<SeriesInfo> query)
|
||||
{
|
||||
var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get box set remote search.
|
||||
/// </summary>
|
||||
/// <param name="query">Remote search query.</param>
|
||||
/// <response code="200">Box set remote search executed.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
|
||||
/// </returns>
|
||||
[HttpPost("Items/RemoteSearch/BoxSet")]
|
||||
public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BoxSetInfo> query)
|
||||
{
|
||||
var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get music artist remote search.
|
||||
/// </summary>
|
||||
/// <param name="query">Remote search query.</param>
|
||||
/// <response code="200">Music artist remote search executed.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
|
||||
/// </returns>
|
||||
[HttpPost("Items/RemoteSearch/MusicArtist")]
|
||||
public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery<ArtistInfo> query)
|
||||
{
|
||||
var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get music album remote search.
|
||||
/// </summary>
|
||||
/// <param name="query">Remote search query.</param>
|
||||
/// <response code="200">Music album remote search executed.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
|
||||
/// </returns>
|
||||
[HttpPost("Items/RemoteSearch/MusicAlbum")]
|
||||
public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery<AlbumInfo> query)
|
||||
{
|
||||
var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get person remote search.
|
||||
/// </summary>
|
||||
/// <param name="query">Remote search query.</param>
|
||||
/// <response code="200">Person remote search executed.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
|
||||
/// </returns>
|
||||
[HttpPost("Items/RemoteSearch/Person")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery<PersonLookupInfo> query)
|
||||
{
|
||||
var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get book remote search.
|
||||
/// </summary>
|
||||
/// <param name="query">Remote search query.</param>
|
||||
/// <response code="200">Book remote search executed.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
|
||||
/// The task result contains an <see cref="OkResult"/> containing the list of remote search results.
|
||||
/// </returns>
|
||||
[HttpPost("Items/RemoteSearch/Book")]
|
||||
public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BookInfo> query)
|
||||
{
|
||||
var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies search criteria to an item and refreshes metadata.
|
||||
/// </summary>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="searchResult">The remote search result.</param>
|
||||
/// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param>
|
||||
/// <response code="204">Item metadata refreshed.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
|
||||
/// The task result contains an <see cref="NoContentResult"/>.
|
||||
/// </returns>
|
||||
[HttpPost("Items/RemoteSearch/Apply/{itemId}")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> ApplySearchCriteria(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromBody, Required] RemoteSearchResult searchResult,
|
||||
[FromQuery] bool replaceAllImages = true)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}",
|
||||
item.Id,
|
||||
item.Name,
|
||||
searchResult.ProviderIds);
|
||||
|
||||
// Since the refresh process won't erase provider Ids, we need to set this explicitly now.
|
||||
item.ProviderIds = searchResult.ProviderIds;
|
||||
await _providerManager.RefreshFullItem(
|
||||
item,
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ReplaceAllMetadata = true,
|
||||
ReplaceAllImages = replaceAllImages,
|
||||
SearchResult = searchResult,
|
||||
RemoveOldMetadata = true
|
||||
},
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
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>
|
||||
[Route("Items")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
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="itemId">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="regenerateTrickplay">(Optional) Determines if trickplay images should be replaced. Only applicable if mode is FullRefresh.</param>
|
||||
/// <response code="204">Item metadata refresh queued.</response>
|
||||
/// <response code="404">Item to refresh not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
|
||||
[HttpPost("{itemId}/Refresh")]
|
||||
[Description("Refreshes metadata for an item.")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult RefreshItem(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None,
|
||||
[FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None,
|
||||
[FromQuery] bool replaceAllMetadata = false,
|
||||
[FromQuery] bool replaceAllImages = false,
|
||||
[FromQuery] bool regenerateTrickplay = false)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is 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,
|
||||
RemoveOldMetadata = replaceAllMetadata,
|
||||
RegenerateTrickplay = regenerateTrickplay
|
||||
};
|
||||
|
||||
_providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Item update controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public class ItemUpdateController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly ILocalizationManager _localizationManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ItemUpdateController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
|
||||
/// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
public ItemUpdateController(
|
||||
IFileSystem fileSystem,
|
||||
ILibraryManager libraryManager,
|
||||
IProviderManager providerManager,
|
||||
ILocalizationManager localizationManager,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_providerManager = providerManager;
|
||||
_localizationManager = localizationManager;
|
||||
_fileSystem = fileSystem;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="request">The new item properties.</param>
|
||||
/// <response code="204">Item updated.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
|
||||
[HttpPost("Items/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var newLockData = request.LockData ?? false;
|
||||
var isLockedChanged = item.IsLocked != newLockData;
|
||||
|
||||
var series = item as Series;
|
||||
var displayOrderChanged = series is not null && !string.Equals(
|
||||
series.DisplayOrder ?? string.Empty,
|
||||
request.DisplayOrder ?? string.Empty,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Do this first so that metadata savers can pull the updates from the database.
|
||||
if (request.People is not null)
|
||||
{
|
||||
_libraryManager.UpdatePeople(
|
||||
item,
|
||||
request.People.Select(x => new PersonInfo
|
||||
{
|
||||
Name = x.Name,
|
||||
Role = x.Role,
|
||||
Type = x.Type
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
await UpdateItem(request, item).ConfigureAwait(false);
|
||||
|
||||
item.OnMetadataChanged();
|
||||
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (isLockedChanged && item.IsFolder)
|
||||
{
|
||||
var folder = (Folder)item;
|
||||
|
||||
foreach (var child in folder.GetRecursiveChildren())
|
||||
{
|
||||
child.IsLocked = newLockData;
|
||||
await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (displayOrderChanged)
|
||||
{
|
||||
_providerManager.QueueRefresh(
|
||||
series!.Id,
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ReplaceAllMetadata = true
|
||||
},
|
||||
RefreshPriority.High);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata editor info for an item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <response code="200">Item metadata editor returned.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
|
||||
[HttpGet("Items/{itemId}/MetadataEditor")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var info = new MetadataEditorInfo
|
||||
{
|
||||
ParentalRatingOptions = _localizationManager.GetParentalRatings().ToList(),
|
||||
ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(),
|
||||
Countries = _localizationManager.GetCountries().ToArray(),
|
||||
Cultures = _localizationManager.GetCultures()
|
||||
.DistinctBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(c => c.DisplayName)
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
if (!item.IsVirtualItem
|
||||
&& item is not ICollectionFolder
|
||||
&& item is not UserView
|
||||
&& item is not AggregateFolder
|
||||
&& item is not LiveTvChannel
|
||||
&& item is not IItemByName
|
||||
&& item.SourceType == SourceType.Library)
|
||||
{
|
||||
var inheritedContentType = _libraryManager.GetInheritedContentType(item);
|
||||
var configuredContentType = _libraryManager.GetConfiguredContentType(item);
|
||||
|
||||
if (inheritedContentType is null || configuredContentType is not null)
|
||||
{
|
||||
info.ContentTypeOptions = GetContentTypeOptions(true).ToArray();
|
||||
info.ContentType = configuredContentType;
|
||||
|
||||
if (inheritedContentType is null
|
||||
|| inheritedContentType == CollectionType.tvshows
|
||||
|| inheritedContentType == CollectionType.movies)
|
||||
{
|
||||
info.ContentTypeOptions = info.ContentTypeOptions
|
||||
.Where(i => string.IsNullOrWhiteSpace(i.Value)
|
||||
|| string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(i.Value, "Movies", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an item's content type.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="contentType">The content type of the item.</param>
|
||||
/// <response code="204">Item content type updated.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns>
|
||||
[HttpPost("Items/{itemId}/ContentType")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var path = item.ContainingFolderPath;
|
||||
|
||||
var types = _serverConfigurationManager.Configuration.ContentTypes
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i.Name))
|
||||
.Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
types.Add(new NameValuePair
|
||||
{
|
||||
Name = path,
|
||||
Value = contentType
|
||||
});
|
||||
}
|
||||
|
||||
_serverConfigurationManager.Configuration.ContentTypes = types.ToArray();
|
||||
_serverConfigurationManager.SaveConfiguration();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task UpdateItem(BaseItemDto request, BaseItem item)
|
||||
{
|
||||
item.Name = request.Name;
|
||||
item.ForcedSortName = request.ForcedSortName;
|
||||
|
||||
item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle;
|
||||
|
||||
item.CriticRating = request.CriticRating;
|
||||
|
||||
item.CommunityRating = request.CommunityRating;
|
||||
item.IndexNumber = request.IndexNumber;
|
||||
item.ParentIndexNumber = request.ParentIndexNumber;
|
||||
item.Overview = request.Overview;
|
||||
item.Genres = request.Genres;
|
||||
|
||||
if (item is Episode episode)
|
||||
{
|
||||
episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber;
|
||||
episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber;
|
||||
episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber;
|
||||
}
|
||||
|
||||
if (request.Height is not null && item is LiveTvChannel channel)
|
||||
{
|
||||
channel.Height = request.Height.Value;
|
||||
}
|
||||
|
||||
if (request.Taglines is not null)
|
||||
{
|
||||
item.Tagline = request.Taglines.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (request.Studios is not null)
|
||||
{
|
||||
item.Studios = Array.ConvertAll(request.Studios, x => x.Name);
|
||||
}
|
||||
|
||||
if (request.DateCreated.HasValue)
|
||||
{
|
||||
item.DateCreated = NormalizeDateTime(request.DateCreated.Value);
|
||||
}
|
||||
|
||||
item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null;
|
||||
item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
|
||||
item.ProductionYear = request.ProductionYear;
|
||||
|
||||
request.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating;
|
||||
item.OfficialRating = request.OfficialRating;
|
||||
item.CustomRating = request.CustomRating;
|
||||
|
||||
var currentTags = item.Tags;
|
||||
var newTags = request.Tags;
|
||||
var removedTags = currentTags.Except(newTags).ToList();
|
||||
var addedTags = newTags.Except(currentTags).ToList();
|
||||
item.Tags = newTags;
|
||||
|
||||
if (item is Series rseries)
|
||||
{
|
||||
foreach (var season in rseries.Children.OfType<Season>())
|
||||
{
|
||||
if (!season.LockedFields.Contains(MetadataField.OfficialRating))
|
||||
{
|
||||
season.OfficialRating = request.OfficialRating;
|
||||
}
|
||||
|
||||
season.CustomRating = request.CustomRating;
|
||||
|
||||
if (!season.LockedFields.Contains(MetadataField.Tags))
|
||||
{
|
||||
season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
season.OnMetadataChanged();
|
||||
await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
foreach (var ep in season.Children.OfType<Episode>())
|
||||
{
|
||||
if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
|
||||
{
|
||||
ep.OfficialRating = request.OfficialRating;
|
||||
}
|
||||
|
||||
ep.CustomRating = request.CustomRating;
|
||||
|
||||
if (!ep.LockedFields.Contains(MetadataField.Tags))
|
||||
{
|
||||
ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
ep.OnMetadataChanged();
|
||||
await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (item is Season season)
|
||||
{
|
||||
foreach (var ep in season.Children.OfType<Episode>())
|
||||
{
|
||||
if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
|
||||
{
|
||||
ep.OfficialRating = request.OfficialRating;
|
||||
}
|
||||
|
||||
ep.CustomRating = request.CustomRating;
|
||||
|
||||
if (!ep.LockedFields.Contains(MetadataField.Tags))
|
||||
{
|
||||
ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
ep.OnMetadataChanged();
|
||||
await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else if (item is MusicAlbum album)
|
||||
{
|
||||
foreach (BaseItem track in album.Children)
|
||||
{
|
||||
if (!track.LockedFields.Contains(MetadataField.OfficialRating))
|
||||
{
|
||||
track.OfficialRating = request.OfficialRating;
|
||||
}
|
||||
|
||||
track.CustomRating = request.CustomRating;
|
||||
|
||||
if (!track.LockedFields.Contains(MetadataField.Tags))
|
||||
{
|
||||
track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
track.OnMetadataChanged();
|
||||
await track.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.ProductionLocations is not null)
|
||||
{
|
||||
item.ProductionLocations = request.ProductionLocations;
|
||||
}
|
||||
|
||||
item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode;
|
||||
item.PreferredMetadataLanguage = request.PreferredMetadataLanguage;
|
||||
|
||||
if (item is IHasDisplayOrder hasDisplayOrder)
|
||||
{
|
||||
hasDisplayOrder.DisplayOrder = request.DisplayOrder;
|
||||
}
|
||||
|
||||
if (item is IHasAspectRatio hasAspectRatio)
|
||||
{
|
||||
hasAspectRatio.AspectRatio = request.AspectRatio;
|
||||
}
|
||||
|
||||
item.IsLocked = request.LockData ?? false;
|
||||
|
||||
if (request.LockedFields is not null)
|
||||
{
|
||||
item.LockedFields = request.LockedFields;
|
||||
}
|
||||
|
||||
// Only allow this for series. Runtimes for media comes from ffprobe.
|
||||
if (item is Series)
|
||||
{
|
||||
item.RunTimeTicks = request.RunTimeTicks;
|
||||
}
|
||||
|
||||
foreach (var pair in request.ProviderIds.ToList())
|
||||
{
|
||||
if (string.IsNullOrEmpty(pair.Value))
|
||||
{
|
||||
request.ProviderIds.Remove(pair.Key);
|
||||
}
|
||||
}
|
||||
|
||||
item.ProviderIds = request.ProviderIds;
|
||||
|
||||
if (item is Video video)
|
||||
{
|
||||
video.Video3DFormat = request.Video3DFormat;
|
||||
}
|
||||
|
||||
if (request.AlbumArtists is not null)
|
||||
{
|
||||
if (item is IHasAlbumArtist hasAlbumArtists)
|
||||
{
|
||||
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (request.ArtistItems is not null)
|
||||
{
|
||||
if (item is IHasArtist hasArtists)
|
||||
{
|
||||
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
switch (item)
|
||||
{
|
||||
case Audio song:
|
||||
song.Album = request.Album;
|
||||
break;
|
||||
case MusicVideo musicVideo:
|
||||
musicVideo.Album = request.Album;
|
||||
break;
|
||||
case Series series:
|
||||
{
|
||||
series.Status = GetSeriesStatus(request);
|
||||
|
||||
if (request.AirDays is not null)
|
||||
{
|
||||
series.AirDays = request.AirDays;
|
||||
series.AirTime = request.AirTime;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SeriesStatus? GetSeriesStatus(BaseItemDto item)
|
||||
{
|
||||
if (string.IsNullOrEmpty(item.Status))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Enum.Parse<SeriesStatus>(item.Status, true);
|
||||
}
|
||||
|
||||
private DateTime NormalizeDateTime(DateTime val)
|
||||
{
|
||||
return DateTime.SpecifyKind(val, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
private List<NameValuePair> GetContentTypeOptions(bool isForItem)
|
||||
{
|
||||
var list = new List<NameValuePair>();
|
||||
|
||||
if (isForItem)
|
||||
{
|
||||
list.Add(new NameValuePair
|
||||
{
|
||||
Name = "Inherit",
|
||||
Value = string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
list.Add(new NameValuePair
|
||||
{
|
||||
Name = "Movies",
|
||||
Value = "movies"
|
||||
});
|
||||
list.Add(new NameValuePair
|
||||
{
|
||||
Name = "Music",
|
||||
Value = "music"
|
||||
});
|
||||
list.Add(new NameValuePair
|
||||
{
|
||||
Name = "Shows",
|
||||
Value = "tvshows"
|
||||
});
|
||||
|
||||
if (!isForItem)
|
||||
{
|
||||
list.Add(new NameValuePair
|
||||
{
|
||||
Name = "Books",
|
||||
Value = "books"
|
||||
});
|
||||
}
|
||||
|
||||
list.Add(new NameValuePair
|
||||
{
|
||||
Name = "HomeVideos",
|
||||
Value = "homevideos"
|
||||
});
|
||||
list.Add(new NameValuePair
|
||||
{
|
||||
Name = "MusicVideos",
|
||||
Value = "musicvideos"
|
||||
});
|
||||
list.Add(new NameValuePair
|
||||
{
|
||||
Name = "Photos",
|
||||
Value = "photos"
|
||||
});
|
||||
|
||||
if (!isForItem)
|
||||
{
|
||||
list.Add(new NameValuePair
|
||||
{
|
||||
Name = "MixedContent",
|
||||
Value = string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var val in list)
|
||||
{
|
||||
val.Name = _localizationManager.GetLocalizedString(val.Name);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Api.Models.LibraryStructureDto;
|
||||
using MediaBrowser.Common.Api;
|
||||
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>
|
||||
/// <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()
|
||||
{
|
||||
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="paths">The paths of the virtual folder.</param>
|
||||
/// <param name="libraryOptionsDto">The library options.</param>
|
||||
/// <param name="refreshLibrary">Whether to refresh the library.</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] CollectionTypeOptions? collectionType,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] paths,
|
||||
[FromBody] AddVirtualFolderDto? libraryOptionsDto,
|
||||
[FromQuery] bool refreshLibrary = false)
|
||||
{
|
||||
var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions();
|
||||
|
||||
if (paths is not null && paths.Length > 0)
|
||||
{
|
||||
libraryOptions.PathInfos = Array.ConvertAll(paths, i => new MediaPathInfo(i));
|
||||
}
|
||||
|
||||
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>
|
||||
/// <response code="404">Folder not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpDelete]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> RemoveVirtualFolder(
|
||||
[FromQuery] string name,
|
||||
[FromQuery] bool refreshLibrary = false)
|
||||
{
|
||||
// TODO: refactor! this relies on an FileNotFound exception to return NotFound when attempting to remove a library that does not exist.
|
||||
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 = false)
|
||||
{
|
||||
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.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
|
||||
var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase));
|
||||
if (newLib is CollectionFolder folder)
|
||||
{
|
||||
foreach (var child in folder.GetPhysicalFolders())
|
||||
{
|
||||
await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
|
||||
await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// We don't know if this one can be validated individually, trigger a new validation
|
||||
await _libraryManager.ValidateMediaLibrary(new Progress<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="mediaPathDto">The media path dto.</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(
|
||||
[FromBody, Required] MediaPathDto mediaPathDto,
|
||||
[FromQuery] bool refreshLibrary = false)
|
||||
{
|
||||
_libraryMonitor.Stop();
|
||||
|
||||
try
|
||||
{
|
||||
var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException("PathInfo and Path can't both be null."));
|
||||
|
||||
_libraryManager.AddMediaPath(mediaPathDto.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 Progress<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="mediaPathRequestDto">The name of the library and path infos.</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([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty");
|
||||
}
|
||||
|
||||
_libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.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="ArgumentException">The name of the library and path may not be empty.</exception>
|
||||
[HttpDelete("Paths")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult RemoveMediaPath(
|
||||
[FromQuery] string name,
|
||||
[FromQuery] string path,
|
||||
[FromQuery] bool refreshLibrary = false)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
_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 Progress<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="request">The library name and options.</param>
|
||||
/// <response code="204">Library updated.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("LibraryOptions")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult UpdateLibraryOptions(
|
||||
[FromBody] UpdateLibraryOptionsDto request)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<CollectionFolder>(request.Id);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
LibraryOptions options = item.GetLibraryOptions();
|
||||
foreach (var mediaPath in request.LibraryOptions!.PathInfos)
|
||||
{
|
||||
if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_libraryManager.CreateShortcut(item.Path, mediaPath);
|
||||
}
|
||||
|
||||
item.UpdateLibraryOptions(request.LibraryOptions);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MediaBrowser.Common.Api;
|
||||
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.FirstTimeSetupOrDefault)]
|
||||
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()
|
||||
{
|
||||
var allCultures = _localization.GetCultures();
|
||||
|
||||
var distinctCultures = allCultures
|
||||
.DistinctBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(c => c.DisplayName)
|
||||
.AsEnumerable();
|
||||
|
||||
return Ok(distinctCultures);
|
||||
}
|
||||
|
||||
/// <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<IReadOnlyList<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<IReadOnlyList<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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Net.Mime;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Lyrics;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Lyrics;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Lyrics controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
public class LyricsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILyricManager _lyricManager;
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LyricsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
|
||||
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
public LyricsController(
|
||||
ILibraryManager libraryManager,
|
||||
ILyricManager lyricManager,
|
||||
IProviderManager providerManager,
|
||||
IFileSystem fileSystem,
|
||||
IUserManager userManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_lyricManager = lyricManager;
|
||||
_providerManager = providerManager;
|
||||
_fileSystem = fileSystem;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an item's lyrics.
|
||||
/// </summary>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Lyrics returned.</response>
|
||||
/// <response code="404">Something went wrong. No Lyrics will be returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns>
|
||||
[HttpGet("Audio/{itemId}/Lyrics")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LyricDto>> GetLyrics([FromRoute, Required] Guid itemId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var result = await _lyricManager.GetLyricsAsync(item, CancellationToken.None).ConfigureAwait(false);
|
||||
if (result is not null)
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload an external lyric file.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item the lyric belongs to.</param>
|
||||
/// <param name="fileName">Name of the file being uploaded.</param>
|
||||
/// <response code="200">Lyrics uploaded.</response>
|
||||
/// <response code="400">Error processing upload.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>The uploaded lyric.</returns>
|
||||
[HttpPost("Audio/{itemId}/Lyrics")]
|
||||
[Authorize(Policy = Policies.LyricManagement)]
|
||||
[AcceptsFile(MediaTypeNames.Text.Plain)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LyricDto>> UploadLyrics(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery, Required] string fileName)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (Request.ContentLength.GetValueOrDefault(0) == 0)
|
||||
{
|
||||
return BadRequest("No lyrics uploaded");
|
||||
}
|
||||
|
||||
// Utilize Path.GetExtension as it provides extra path validation.
|
||||
var format = Path.GetExtension(fileName.AsSpan()).RightPart('.').ToString();
|
||||
if (string.IsNullOrEmpty(format))
|
||||
{
|
||||
return BadRequest("Extension is required on filename");
|
||||
}
|
||||
|
||||
var stream = new MemoryStream();
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
await Request.Body.CopyToAsync(stream).ConfigureAwait(false);
|
||||
var uploadedLyric = await _lyricManager.SaveLyricAsync(
|
||||
item,
|
||||
format,
|
||||
stream)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (uploadedLyric is null)
|
||||
{
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
_providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
|
||||
return Ok(uploadedLyric);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an external lyric file.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <response code="204">Lyric deleted.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpDelete("Audio/{itemId}/Lyrics")]
|
||||
[Authorize(Policy = Policies.LyricManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DeleteLyrics(
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await _lyricManager.DeleteLyricsAsync(item).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search remote lyrics.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <response code="200">Lyrics retrieved.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>An array of <see cref="RemoteLyricInfo"/>.</returns>
|
||||
[HttpGet("Audio/{itemId}/RemoteSearch/Lyrics")]
|
||||
[Authorize(Policy = Policies.LyricManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IReadOnlyList<RemoteLyricInfoDto>>> SearchRemoteLyrics([FromRoute, Required] Guid itemId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var results = await _lyricManager.SearchLyricsAsync(item, false, CancellationToken.None).ConfigureAwait(false);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a remote lyric.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="lyricId">The lyric id.</param>
|
||||
/// <response code="200">Lyric downloaded.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Audio/{itemId}/RemoteSearch/Lyrics/{lyricId}")]
|
||||
[Authorize(Policy = Policies.LyricManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LyricDto>> DownloadRemoteLyrics(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] string lyricId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(item, lyricId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (downloadedLyrics is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
|
||||
return Ok(downloadedLyrics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remote lyrics.
|
||||
/// </summary>
|
||||
/// <param name="lyricId">The remote provider item id.</param>
|
||||
/// <response code="200">File returned.</response>
|
||||
/// <response code="404">Lyric not found.</response>
|
||||
/// <returns>A <see cref="FileStreamResult"/> with the lyric file.</returns>
|
||||
[HttpGet("Providers/Lyrics/{lyricId}")]
|
||||
[Authorize(Policy = Policies.LyricManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LyricDto>> GetRemoteLyrics([FromRoute, Required] string lyricId)
|
||||
{
|
||||
var result = await _lyricManager.GetRemoteLyricsAsync(lyricId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.Models.MediaInfoDtos;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The media info controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize]
|
||||
public class MediaInfoController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<MediaInfoController> _logger;
|
||||
private readonly MediaInfoHelper _mediaInfoHelper;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaInfoController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param>
|
||||
/// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface..</param>
|
||||
public MediaInfoController(
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IDeviceManager deviceManager,
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<MediaInfoController> logger,
|
||||
MediaInfoHelper mediaInfoHelper,
|
||||
IUserManager userManager)
|
||||
{
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_deviceManager = deviceManager;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_mediaInfoHelper = mediaInfoHelper;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets live playback media info for an item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <response code="200">Playback info returned.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns>
|
||||
[HttpGet("Items/{itemId}/PlaybackInfo")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets live playback media info for an item.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.
|
||||
/// Query parameters are obsolete.
|
||||
/// </remarks>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
|
||||
/// <param name="startTimeTicks">The start time in ticks.</param>
|
||||
/// <param name="audioStreamIndex">The audio stream index.</param>
|
||||
/// <param name="subtitleStreamIndex">The subtitle stream index.</param>
|
||||
/// <param name="maxAudioChannels">The maximum number of audio channels.</param>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <param name="liveStreamId">The livestream id.</param>
|
||||
/// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param>
|
||||
/// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
|
||||
/// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
|
||||
/// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param>
|
||||
/// <param name="playbackInfoDto">The playback info.</param>
|
||||
/// <response code="200">Playback info returned.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns>
|
||||
[HttpPost("Items/{itemId}/PlaybackInfo")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery, ParameterObsolete] Guid? userId,
|
||||
[FromQuery, ParameterObsolete] int? maxStreamingBitrate,
|
||||
[FromQuery, ParameterObsolete] long? startTimeTicks,
|
||||
[FromQuery, ParameterObsolete] int? audioStreamIndex,
|
||||
[FromQuery, ParameterObsolete] int? subtitleStreamIndex,
|
||||
[FromQuery, ParameterObsolete] int? maxAudioChannels,
|
||||
[FromQuery, ParameterObsolete] string? mediaSourceId,
|
||||
[FromQuery, ParameterObsolete] string? liveStreamId,
|
||||
[FromQuery, ParameterObsolete] bool? autoOpenLiveStream,
|
||||
[FromQuery, ParameterObsolete] bool? enableDirectPlay,
|
||||
[FromQuery, ParameterObsolete] bool? enableDirectStream,
|
||||
[FromQuery, ParameterObsolete] bool? enableTranscoding,
|
||||
[FromQuery, ParameterObsolete] bool? allowVideoStreamCopy,
|
||||
[FromQuery, ParameterObsolete] bool? allowAudioStreamCopy,
|
||||
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto)
|
||||
{
|
||||
var profile = playbackInfoDto?.DeviceProfile;
|
||||
_logger.LogDebug("GetPostedPlaybackInfo profile: {@Profile}", profile);
|
||||
|
||||
if (profile is null)
|
||||
{
|
||||
var caps = _deviceManager.GetCapabilities(User.GetDeviceId());
|
||||
if (caps is not null)
|
||||
{
|
||||
profile = caps.DeviceProfile;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy params from posted body
|
||||
// TODO clean up when breaking API compatibility.
|
||||
userId ??= playbackInfoDto?.UserId;
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate;
|
||||
startTimeTicks ??= playbackInfoDto?.StartTimeTicks;
|
||||
audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex;
|
||||
subtitleStreamIndex ??= playbackInfoDto?.SubtitleStreamIndex;
|
||||
maxAudioChannels ??= playbackInfoDto?.MaxAudioChannels;
|
||||
mediaSourceId ??= playbackInfoDto?.MediaSourceId;
|
||||
liveStreamId ??= playbackInfoDto?.LiveStreamId;
|
||||
autoOpenLiveStream ??= playbackInfoDto?.AutoOpenLiveStream ?? false;
|
||||
enableDirectPlay ??= playbackInfoDto?.EnableDirectPlay ?? true;
|
||||
enableDirectStream ??= playbackInfoDto?.EnableDirectStream ?? true;
|
||||
enableTranscoding ??= playbackInfoDto?.EnableTranscoding ?? true;
|
||||
allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true;
|
||||
allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true;
|
||||
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var info = await _mediaInfoHelper.GetPlaybackInfo(
|
||||
item,
|
||||
user,
|
||||
mediaSourceId,
|
||||
liveStreamId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (info.ErrorCode is not null)
|
||||
{
|
||||
return info;
|
||||
}
|
||||
|
||||
if (profile is not null)
|
||||
{
|
||||
// set device specific data
|
||||
foreach (var mediaSource in info.MediaSources)
|
||||
{
|
||||
_mediaInfoHelper.SetDeviceSpecificData(
|
||||
item,
|
||||
mediaSource,
|
||||
profile,
|
||||
User,
|
||||
maxStreamingBitrate ?? profile.MaxStreamingBitrate,
|
||||
startTimeTicks ?? 0,
|
||||
mediaSourceId ?? string.Empty,
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex,
|
||||
maxAudioChannels,
|
||||
info.PlaySessionId!,
|
||||
userId ?? Guid.Empty,
|
||||
enableDirectPlay.Value,
|
||||
enableDirectStream.Value,
|
||||
enableTranscoding.Value,
|
||||
allowVideoStreamCopy.Value,
|
||||
allowAudioStreamCopy.Value,
|
||||
playbackInfoDto?.AlwaysBurnInSubtitleWhenTranscoding ?? false,
|
||||
Request.HttpContext.GetNormalizedRemoteIP());
|
||||
}
|
||||
|
||||
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
|
||||
}
|
||||
|
||||
if (autoOpenLiveStream.Value)
|
||||
{
|
||||
var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal));
|
||||
|
||||
if (mediaSource is not null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId))
|
||||
{
|
||||
var openStreamResult = await _mediaInfoHelper.OpenMediaSource(
|
||||
HttpContext,
|
||||
new LiveStreamRequest
|
||||
{
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
DeviceProfile = playbackInfoDto?.DeviceProfile,
|
||||
EnableDirectPlay = enableDirectPlay.Value,
|
||||
EnableDirectStream = enableDirectStream.Value,
|
||||
ItemId = itemId,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
MaxStreamingBitrate = maxStreamingBitrate,
|
||||
PlaySessionId = info.PlaySessionId,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
UserId = userId ?? Guid.Empty,
|
||||
OpenToken = mediaSource.OpenToken,
|
||||
AlwaysBurnInSubtitleWhenTranscoding = playbackInfoDto?.AlwaysBurnInSubtitleWhenTranscoding ?? false
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
info.MediaSources = new[] { openStreamResult.MediaSource };
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a media source.
|
||||
/// </summary>
|
||||
/// <param name="openToken">The open token.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
|
||||
/// <param name="startTimeTicks">The start time in ticks.</param>
|
||||
/// <param name="audioStreamIndex">The audio stream index.</param>
|
||||
/// <param name="subtitleStreamIndex">The subtitle stream index.</param>
|
||||
/// <param name="maxAudioChannels">The maximum number of audio channels.</param>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="openLiveStreamDto">The open live stream dto.</param>
|
||||
/// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
|
||||
/// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
|
||||
/// <param name="alwaysBurnInSubtitleWhenTranscoding">Always burn-in subtitle when transcoding.</param>
|
||||
/// <response code="200">Media source opened.</response>
|
||||
/// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns>
|
||||
[HttpPost("LiveStreams/Open")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LiveStreamResponse>> OpenLiveStream(
|
||||
[FromQuery] string? openToken,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] int? maxStreamingBitrate,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] Guid? itemId,
|
||||
[FromBody] OpenLiveStreamDto? openLiveStreamDto,
|
||||
[FromQuery] bool? enableDirectPlay,
|
||||
[FromQuery] bool? enableDirectStream,
|
||||
[FromQuery] bool? alwaysBurnInSubtitleWhenTranscoding)
|
||||
{
|
||||
userId ??= openLiveStreamDto?.UserId;
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var request = new LiveStreamRequest
|
||||
{
|
||||
OpenToken = openToken ?? openLiveStreamDto?.OpenToken,
|
||||
UserId = userId.Value,
|
||||
PlaySessionId = playSessionId ?? openLiveStreamDto?.PlaySessionId,
|
||||
MaxStreamingBitrate = maxStreamingBitrate ?? openLiveStreamDto?.MaxStreamingBitrate,
|
||||
StartTimeTicks = startTimeTicks ?? openLiveStreamDto?.StartTimeTicks,
|
||||
AudioStreamIndex = audioStreamIndex ?? openLiveStreamDto?.AudioStreamIndex,
|
||||
SubtitleStreamIndex = subtitleStreamIndex ?? openLiveStreamDto?.SubtitleStreamIndex,
|
||||
MaxAudioChannels = maxAudioChannels ?? openLiveStreamDto?.MaxAudioChannels,
|
||||
ItemId = itemId ?? openLiveStreamDto?.ItemId ?? Guid.Empty,
|
||||
DeviceProfile = openLiveStreamDto?.DeviceProfile,
|
||||
EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true,
|
||||
EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true,
|
||||
DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http },
|
||||
AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding ?? openLiveStreamDto?.AlwaysBurnInSubtitleWhenTranscoding ?? false
|
||||
};
|
||||
return await _mediaInfoHelper.OpenMediaSource(HttpContext, request).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes a media source.
|
||||
/// </summary>
|
||||
/// <param name="liveStreamId">The livestream id.</param>
|
||||
/// <response code="204">Livestream closed.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("LiveStreams/Close")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> CloseLiveStream([FromQuery, Required] string liveStreamId)
|
||||
{
|
||||
await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the network with a request with the size of the bitrate.
|
||||
/// </summary>
|
||||
/// <param name="size">The bitrate. Defaults to 102400.</param>
|
||||
/// <response code="200">Test buffer returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> with specified bitrate.</returns>
|
||||
[HttpGet("Playback/BitrateTest")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesFile(MediaTypeNames.Application.Octet)]
|
||||
public ActionResult GetBitrateTestBytes([FromQuery][Range(1, 100_000_000, ErrorMessage = "The requested size must be greater than or equal to {1} and less than or equal to {2}")] int size = 102400)
|
||||
{
|
||||
byte[] buffer = ArrayPool<byte>.Shared.Rent(size);
|
||||
try
|
||||
{
|
||||
Random.Shared.NextBytes(buffer);
|
||||
return File(buffer, MediaTypeNames.Application.Octet);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaSegments;
|
||||
using MediaBrowser.Model.MediaSegments;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Media Segments api.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class MediaSegmentsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IMediaSegmentManager _mediaSegmentManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaSegmentsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="mediaSegmentManager">MediaSegments Manager.</param>
|
||||
/// <param name="libraryManager">The Library manager.</param>
|
||||
public MediaSegmentsController(IMediaSegmentManager mediaSegmentManager, ILibraryManager libraryManager)
|
||||
{
|
||||
_mediaSegmentManager = mediaSegmentManager;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all media segments based on an itemId.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The ItemId.</param>
|
||||
/// <param name="includeSegmentTypes">Optional filter of requested segment types.</param>
|
||||
/// <returns>A list of media segment objects related to the requested itemId.</returns>
|
||||
[HttpGet("{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<QueryResult<MediaSegmentDto>>> GetItemSegments(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] IEnumerable<MediaSegmentType>? includeSegmentTypes = null)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var libraryOptions = _libraryManager.GetLibraryOptions(item);
|
||||
var items = await _mediaSegmentManager.GetSegmentsAsync(item, includeSegmentTypes, libraryOptions).ConfigureAwait(false);
|
||||
return Ok(new QueryResult<MediaSegmentDto>(items.ToArray()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Movies controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class MoviesController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MoviesController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
public MoviesController(
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService,
|
||||
IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
_dtoService = dtoService;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets movie recommendations.
|
||||
/// </summary>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
|
||||
/// <param name="fields">Optional. The fields to return.</param>
|
||||
/// <param name="categoryLimit">The max number of categories to return.</param>
|
||||
/// <param name="itemLimit">The max number of items to return per category.</param>
|
||||
/// <response code="200">Movie recommendations returned.</response>
|
||||
/// <returns>The list of movie recommendations.</returns>
|
||||
[HttpGet("Recommendations")]
|
||||
public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] int categoryLimit = 5,
|
||||
[FromQuery] int itemLimit = 8)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var dtoOptions = new DtoOptions { Fields = fields };
|
||||
|
||||
var categories = new List<RecommendationDto>();
|
||||
|
||||
var parentIdGuid = parentId ?? Guid.Empty;
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = new[]
|
||||
{
|
||||
BaseItemKind.Movie,
|
||||
// nameof(Trailer),
|
||||
// nameof(LiveTvProgram)
|
||||
},
|
||||
// IsMovie = true
|
||||
OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) },
|
||||
Limit = 7,
|
||||
ParentId = parentIdGuid,
|
||||
Recursive = true,
|
||||
IsPlayed = true,
|
||||
DtoOptions = dtoOptions
|
||||
};
|
||||
|
||||
var recentlyPlayedMovies = _libraryManager.GetItemList(query);
|
||||
|
||||
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||
{
|
||||
itemTypes.Add(BaseItemKind.Trailer);
|
||||
itemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||
}
|
||||
|
||||
var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = itemTypes.ToArray(),
|
||||
IsMovie = true,
|
||||
OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) },
|
||||
Limit = 10,
|
||||
IsFavoriteOrLiked = true,
|
||||
ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
|
||||
EnableGroupByMetadataKey = true,
|
||||
ParentId = parentIdGuid,
|
||||
Recursive = true,
|
||||
DtoOptions = dtoOptions
|
||||
});
|
||||
|
||||
var mostRecentMovies = recentlyPlayedMovies.Take(Math.Min(recentlyPlayedMovies.Count, 6)).ToList();
|
||||
// Get recently played directors
|
||||
var recentDirectors = GetDirectors(mostRecentMovies)
|
||||
.ToList();
|
||||
|
||||
// Get recently played actors
|
||||
var recentActors = GetActors(mostRecentMovies)
|
||||
.ToList();
|
||||
|
||||
var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator();
|
||||
var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator();
|
||||
|
||||
var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator();
|
||||
var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator();
|
||||
|
||||
var categoryTypes = new List<IEnumerator<RecommendationDto>>
|
||||
{
|
||||
// Give this extra weight
|
||||
similarToRecentlyPlayed,
|
||||
similarToRecentlyPlayed,
|
||||
|
||||
// Give this extra weight
|
||||
similarToLiked,
|
||||
similarToLiked,
|
||||
hasDirectorFromRecentlyPlayed,
|
||||
hasActorFromRecentlyPlayed
|
||||
};
|
||||
|
||||
while (categories.Count < categoryLimit)
|
||||
{
|
||||
var allEmpty = true;
|
||||
|
||||
foreach (var category in categoryTypes)
|
||||
{
|
||||
if (category.MoveNext())
|
||||
{
|
||||
categories.Add(category.Current);
|
||||
allEmpty = false;
|
||||
|
||||
if (categories.Count >= categoryLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allEmpty)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable());
|
||||
}
|
||||
|
||||
private IEnumerable<RecommendationDto> GetWithDirector(
|
||||
User? user,
|
||||
IEnumerable<string> names,
|
||||
int itemLimit,
|
||||
DtoOptions dtoOptions,
|
||||
RecommendationType type)
|
||||
{
|
||||
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||
{
|
||||
itemTypes.Add(BaseItemKind.Trailer);
|
||||
itemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||
}
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
var items = _libraryManager.GetItemList(
|
||||
new InternalItemsQuery(user)
|
||||
{
|
||||
Person = name,
|
||||
// Account for duplicates by IMDb id, since the database doesn't support this yet
|
||||
Limit = itemLimit + 2,
|
||||
PersonTypes = new[] { PersonType.Director },
|
||||
IncludeItemTypes = itemTypes.ToArray(),
|
||||
IsMovie = true,
|
||||
EnableGroupByMetadataKey = true,
|
||||
DtoOptions = dtoOptions
|
||||
}).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
|
||||
.Take(itemLimit)
|
||||
.ToList();
|
||||
|
||||
if (items.Count > 0)
|
||||
{
|
||||
var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
|
||||
|
||||
yield return new RecommendationDto
|
||||
{
|
||||
BaselineItemName = name,
|
||||
CategoryId = name.GetMD5(),
|
||||
RecommendationType = type,
|
||||
Items = returnItems
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
|
||||
{
|
||||
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||
{
|
||||
itemTypes.Add(BaseItemKind.Trailer);
|
||||
itemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||
}
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
Person = name,
|
||||
// Account for duplicates by IMDb id, since the database doesn't support this yet
|
||||
Limit = itemLimit + 2,
|
||||
IncludeItemTypes = itemTypes.ToArray(),
|
||||
IsMovie = true,
|
||||
EnableGroupByMetadataKey = true,
|
||||
DtoOptions = dtoOptions
|
||||
}).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
|
||||
.Take(itemLimit)
|
||||
.ToList();
|
||||
|
||||
if (items.Count > 0)
|
||||
{
|
||||
var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
|
||||
|
||||
yield return new RecommendationDto
|
||||
{
|
||||
BaselineItemName = name,
|
||||
CategoryId = name.GetMD5(),
|
||||
RecommendationType = type,
|
||||
Items = returnItems
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
|
||||
{
|
||||
var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie };
|
||||
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
|
||||
{
|
||||
itemTypes.Add(BaseItemKind.Trailer);
|
||||
itemTypes.Add(BaseItemKind.LiveTvProgram);
|
||||
}
|
||||
|
||||
foreach (var item in baselineItems)
|
||||
{
|
||||
var similar = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
Limit = itemLimit,
|
||||
IncludeItemTypes = itemTypes.ToArray(),
|
||||
IsMovie = true,
|
||||
EnableGroupByMetadataKey = true,
|
||||
DtoOptions = dtoOptions
|
||||
});
|
||||
|
||||
if (similar.Count > 0)
|
||||
{
|
||||
var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user);
|
||||
|
||||
yield return new RecommendationDto
|
||||
{
|
||||
BaselineItemName = item.Name,
|
||||
CategoryId = item.Id,
|
||||
RecommendationType = type,
|
||||
Items = returnItems
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetActors(IEnumerable<BaseItem> items)
|
||||
{
|
||||
var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director })
|
||||
{
|
||||
MaxListOrder = 3
|
||||
});
|
||||
|
||||
var itemIds = items.Select(i => i.Id).ToList();
|
||||
|
||||
return people
|
||||
.Where(i => itemIds.Contains(i.ItemId))
|
||||
.Select(i => i.Name)
|
||||
.DistinctNames();
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items)
|
||||
{
|
||||
var people = _libraryManager.GetPeople(new InternalPeopleQuery(
|
||||
new[] { PersonType.Director },
|
||||
Array.Empty<string>()));
|
||||
|
||||
var itemIds = items.Select(i => i.Id).ToList();
|
||||
|
||||
return people
|
||||
.Where(i => itemIds.Contains(i.ItemId))
|
||||
.Select(i => i.Name)
|
||||
.DistinctNames();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The music genres controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class MusicGenresController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MusicGenresController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
|
||||
public MusicGenresController(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDtoService dtoService)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dtoService = dtoService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all music genres from a given item, folder, or the entire library.
|
||||
/// </summary>
|
||||
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="searchTerm">The search term.</param>
|
||||
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
|
||||
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
|
||||
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
|
||||
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
|
||||
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param>
|
||||
/// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
|
||||
/// <param name="enableImages">Optional, include image information in output.</param>
|
||||
/// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
|
||||
/// <response code="200">Music genres returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns>
|
||||
[HttpGet]
|
||||
[Obsolete("Use GetGenres instead")]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? searchTerm,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery] bool? isFavorite,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? nameStartsWithOrGreater,
|
||||
[FromQuery] string? nameStartsWith,
|
||||
[FromQuery] string? nameLessThan,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
|
||||
[FromQuery] bool? enableImages = true,
|
||||
[FromQuery] bool enableTotalRecordCount = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
var parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
StartIndex = startIndex,
|
||||
Limit = limit,
|
||||
IsFavorite = isFavorite,
|
||||
NameLessThan = nameLessThan,
|
||||
NameStartsWith = nameStartsWith,
|
||||
NameStartsWithOrGreater = nameStartsWithOrGreater,
|
||||
DtoOptions = dtoOptions,
|
||||
SearchTerm = searchTerm,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder)
|
||||
};
|
||||
|
||||
if (parentId.HasValue)
|
||||
{
|
||||
if (parentItem is Folder)
|
||||
{
|
||||
query.AncestorIds = new[] { parentId.Value };
|
||||
}
|
||||
else
|
||||
{
|
||||
query.ItemIds = new[] { parentId.Value };
|
||||
}
|
||||
}
|
||||
|
||||
var result = _libraryManager.GetMusicGenres(query);
|
||||
|
||||
var shouldIncludeItemTypes = includeItemTypes.Length != 0;
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a music genre, by name.
|
||||
/// </summary>
|
||||
/// <param name="genreName">The genre name.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns>
|
||||
[HttpGet("{genreName}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
MusicGenre? item;
|
||||
|
||||
if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions, BaseItemKind.MusicGenre);
|
||||
}
|
||||
else
|
||||
{
|
||||
item = _libraryManager.GetMusicGenre(genreName);
|
||||
}
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions);
|
||||
}
|
||||
|
||||
private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind)
|
||||
where T : BaseItem, new()
|
||||
{
|
||||
var result = libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
Name = name.Replace(BaseItem.SlugChar, '&'),
|
||||
IncludeItemTypes = new[] { baseItemKind },
|
||||
DtoOptions = dtoOptions
|
||||
}).OfType<T>().FirstOrDefault();
|
||||
|
||||
result ??= libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
Name = name.Replace(BaseItem.SlugChar, '/'),
|
||||
IncludeItemTypes = new[] { baseItemKind },
|
||||
DtoOptions = dtoOptions
|
||||
}).OfType<T>().FirstOrDefault();
|
||||
|
||||
result ??= libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
Name = name.Replace(BaseItem.SlugChar, '?'),
|
||||
IncludeItemTypes = new[] { baseItemKind },
|
||||
DtoOptions = dtoOptions
|
||||
}).OfType<T>().FirstOrDefault();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Updates;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Model.Updates;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Package Controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public class PackageController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IInstallationManager _installationManager;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PackageController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager)
|
||||
{
|
||||
_installationManager = installationManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a package by name or assembly GUID.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the package.</param>
|
||||
/// <param name="assemblyGuid">The GUID of the associated assembly.</param>
|
||||
/// <response code="200">Package retrieved.</response>
|
||||
/// <returns>A <see cref="PackageInfo"/> containing package information.</returns>
|
||||
[HttpGet("Packages/{name}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PackageInfo>> GetPackageInfo(
|
||||
[FromRoute, Required] string name,
|
||||
[FromQuery] Guid? assemblyGuid)
|
||||
{
|
||||
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
|
||||
var result = _installationManager.FilterPackages(
|
||||
packages,
|
||||
name,
|
||||
assemblyGuid ?? default)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets available packages.
|
||||
/// </summary>
|
||||
/// <response code="200">Available packages returned.</response>
|
||||
/// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns>
|
||||
[HttpGet("Packages")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<IEnumerable<PackageInfo>> GetPackages()
|
||||
{
|
||||
IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs a package.
|
||||
/// </summary>
|
||||
/// <param name="name">Package name.</param>
|
||||
/// <param name="assemblyGuid">GUID of the associated assembly.</param>
|
||||
/// <param name="version">Optional version. Defaults to latest version.</param>
|
||||
/// <param name="repositoryUrl">Optional. Specify the repository to install from.</param>
|
||||
/// <response code="204">Package found.</response>
|
||||
/// <response code="404">Package not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns>
|
||||
[HttpPost("Packages/Installed/{name}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> InstallPackage(
|
||||
[FromRoute, Required] string name,
|
||||
[FromQuery] Guid? assemblyGuid,
|
||||
[FromQuery] string? version,
|
||||
[FromQuery] string? repositoryUrl)
|
||||
{
|
||||
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(repositoryUrl))
|
||||
{
|
||||
packages = packages.Where(p => p.Versions.Any(q => q.RepositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var package = _installationManager.GetCompatibleVersions(
|
||||
packages,
|
||||
name,
|
||||
assemblyGuid ?? Guid.Empty,
|
||||
specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (package is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await _installationManager.InstallPackage(package).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a package installation.
|
||||
/// </summary>
|
||||
/// <param name="packageId">Installation Id.</param>
|
||||
/// <response code="204">Installation cancelled.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns>
|
||||
[HttpDelete("Packages/Installing/{packageId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult CancelPackageInstallation(
|
||||
[FromRoute, Required] Guid packageId)
|
||||
{
|
||||
_installationManager.CancelInstallation(packageId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all package repositories.
|
||||
/// </summary>
|
||||
/// <response code="200">Package repositories returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns>
|
||||
[HttpGet("Repositories")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories()
|
||||
{
|
||||
return Ok(_serverConfigurationManager.Configuration.PluginRepositories.AsEnumerable());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the enabled and existing package repositories.
|
||||
/// </summary>
|
||||
/// <param name="repositoryInfos">The list of package repositories.</param>
|
||||
/// <response code="204">Package repositories saved.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Repositories")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos)
|
||||
{
|
||||
_serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos;
|
||||
_serverConfigurationManager.SaveConfiguration();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Persons controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class PersonsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PersonsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
public PersonsController(
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService,
|
||||
IUserManager userManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_dtoService = dtoService;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all persons.
|
||||
/// </summary>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="searchTerm">The search term.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="filters">Optional. Specify additional filters to apply.</param>
|
||||
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param>
|
||||
/// <param name="enableUserData">Optional, include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param>
|
||||
/// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param>
|
||||
/// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="enableImages">Optional, include image information in output.</param>
|
||||
/// <response code="200">Persons returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetPersons(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? searchTerm,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
|
||||
[FromQuery] bool? isFavorite,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] excludePersonTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes,
|
||||
[FromQuery] Guid? appearsInItemId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] bool? enableImages = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
|
||||
var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery(
|
||||
personTypes,
|
||||
excludePersonTypes)
|
||||
{
|
||||
NameContains = searchTerm,
|
||||
User = user,
|
||||
IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,
|
||||
AppearsInItemId = appearsInItemId ?? Guid.Empty,
|
||||
Limit = limit ?? 0
|
||||
});
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
peopleItems
|
||||
.Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get person by name.
|
||||
/// </summary>
|
||||
/// <param name="name">Person name.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <response code="200">Person returned.</response>
|
||||
/// <response code="404">Person not found.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the person on success,
|
||||
/// or a <see cref="NotFoundResult"/> if person not found.</returns>
|
||||
[HttpGet("{name}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
var item = _libraryManager.GetPerson(name);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Api.Models.PlaylistDtos;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Playlists;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Playlists;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Playlists controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class PlaylistsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IPlaylistManager _playlistManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlaylistsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public PlaylistsController(
|
||||
IDtoService dtoService,
|
||||
IPlaylistManager playlistManager,
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_dtoService = dtoService;
|
||||
_playlistManager = playlistManager;
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new playlist.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.
|
||||
/// Query parameters are obsolete.
|
||||
/// </remarks>
|
||||
/// <param name="name">The playlist name.</param>
|
||||
/// <param name="ids">The item ids.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="mediaType">The media type.</param>
|
||||
/// <param name="createPlaylistRequest">The create playlist payload.</param>
|
||||
/// <response code="200">Playlist created.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to create a playlist.
|
||||
/// The task result contains an <see cref="OkResult"/> indicating success.
|
||||
/// </returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
|
||||
[FromQuery, ParameterObsolete] string? name,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder)), ParameterObsolete] IReadOnlyList<Guid> ids,
|
||||
[FromQuery, ParameterObsolete] Guid? userId,
|
||||
[FromQuery, ParameterObsolete] MediaType? mediaType,
|
||||
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest)
|
||||
{
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>();
|
||||
}
|
||||
|
||||
userId ??= createPlaylistRequest?.UserId ?? default;
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
|
||||
{
|
||||
Name = name ?? createPlaylistRequest?.Name,
|
||||
ItemIdList = ids,
|
||||
UserId = userId.Value,
|
||||
MediaType = mediaType ?? createPlaylistRequest?.MediaType,
|
||||
Users = createPlaylistRequest?.Users.ToArray() ?? [],
|
||||
Public = createPlaylistRequest?.IsPublic
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a playlist.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <param name="updatePlaylistRequest">The <see cref="UpdatePlaylistDto"/> id.</param>
|
||||
/// <response code="204">Playlist updated.</response>
|
||||
/// <response code="403">Access forbidden.</response>
|
||||
/// <response code="404">Playlist not found.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to update a playlist.
|
||||
/// The task result contains an <see cref="OkResult"/> indicating success.
|
||||
/// </returns>
|
||||
[HttpPost("{playlistId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdatePlaylist(
|
||||
[FromRoute, Required] Guid playlistId,
|
||||
[FromBody, Required] UpdatePlaylistDto updatePlaylistRequest)
|
||||
{
|
||||
var callingUserId = User.GetUserId();
|
||||
|
||||
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
|
||||
if (playlist is null)
|
||||
{
|
||||
return NotFound("Playlist not found");
|
||||
}
|
||||
|
||||
var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
|
||||
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId));
|
||||
|
||||
if (!isPermitted)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
await _playlistManager.UpdatePlaylist(new PlaylistUpdateRequest
|
||||
{
|
||||
UserId = callingUserId,
|
||||
Id = playlistId,
|
||||
Name = updatePlaylistRequest.Name,
|
||||
Ids = updatePlaylistRequest.Ids,
|
||||
Users = updatePlaylistRequest.Users,
|
||||
Public = updatePlaylistRequest.IsPublic
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a playlist.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <response code="200">The playlist.</response>
|
||||
/// <response code="404">Playlist not found.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Playlist"/> objects.
|
||||
/// </returns>
|
||||
[HttpGet("{playlistId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<PlaylistDto> GetPlaylist(
|
||||
[FromRoute, Required] Guid playlistId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId);
|
||||
if (playlist is null)
|
||||
{
|
||||
return NotFound("Playlist not found");
|
||||
}
|
||||
|
||||
return new PlaylistDto()
|
||||
{
|
||||
Shares = playlist.Shares,
|
||||
OpenAccess = playlist.OpenAccess,
|
||||
ItemIds = playlist.GetManageableItems().Select(t => t.Item2.Id).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a playlist's users.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <response code="200">Found shares.</response>
|
||||
/// <response code="403">Access forbidden.</response>
|
||||
/// <response code="404">Playlist not found.</response>
|
||||
/// <returns>
|
||||
/// A list of <see cref="PlaylistUserPermissions"/> objects.
|
||||
/// </returns>
|
||||
[HttpGet("{playlistId}/Users")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<IReadOnlyList<PlaylistUserPermissions>> GetPlaylistUsers(
|
||||
[FromRoute, Required] Guid playlistId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId);
|
||||
if (playlist is null)
|
||||
{
|
||||
return NotFound("Playlist not found");
|
||||
}
|
||||
|
||||
var isPermitted = playlist.OwnerUserId.Equals(userId);
|
||||
|
||||
return isPermitted ? playlist.Shares.ToList() : Forbid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a playlist user.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <response code="200">User permission found.</response>
|
||||
/// <response code="403">Access forbidden.</response>
|
||||
/// <response code="404">Playlist not found.</response>
|
||||
/// <returns>
|
||||
/// <see cref="PlaylistUserPermissions"/>.
|
||||
/// </returns>
|
||||
[HttpGet("{playlistId}/Users/{userId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<PlaylistUserPermissions?> GetPlaylistUser(
|
||||
[FromRoute, Required] Guid playlistId,
|
||||
[FromRoute, Required] Guid userId)
|
||||
{
|
||||
var callingUserId = User.GetUserId();
|
||||
|
||||
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
|
||||
if (playlist is null)
|
||||
{
|
||||
return NotFound("Playlist not found");
|
||||
}
|
||||
|
||||
if (playlist.OwnerUserId.Equals(callingUserId))
|
||||
{
|
||||
return new PlaylistUserPermissions(callingUserId, true);
|
||||
}
|
||||
|
||||
var userPermission = playlist.Shares.FirstOrDefault(s => s.UserId.Equals(userId));
|
||||
var isPermitted = playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId))
|
||||
|| userId.Equals(callingUserId);
|
||||
|
||||
if (!isPermitted)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
if (userPermission is not null)
|
||||
{
|
||||
return userPermission;
|
||||
}
|
||||
|
||||
return NotFound("User permissions not found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modify a user of a playlist's users.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="updatePlaylistUserRequest">The <see cref="UpdatePlaylistUserDto"/>.</param>
|
||||
/// <response code="204">User's permissions modified.</response>
|
||||
/// <response code="403">Access forbidden.</response>
|
||||
/// <response code="404">Playlist not found.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to modify an user's playlist permissions.
|
||||
/// The task result contains an <see cref="OkResult"/> indicating success.
|
||||
/// </returns>
|
||||
[HttpPost("{playlistId}/Users/{userId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdatePlaylistUser(
|
||||
[FromRoute, Required] Guid playlistId,
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow), Required] UpdatePlaylistUserDto updatePlaylistUserRequest)
|
||||
{
|
||||
var callingUserId = User.GetUserId();
|
||||
|
||||
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
|
||||
if (playlist is null)
|
||||
{
|
||||
return NotFound("Playlist not found");
|
||||
}
|
||||
|
||||
var isPermitted = playlist.OwnerUserId.Equals(callingUserId);
|
||||
|
||||
if (!isPermitted)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
await _playlistManager.AddUserToShares(new PlaylistUserUpdateRequest
|
||||
{
|
||||
Id = playlistId,
|
||||
UserId = userId,
|
||||
CanEdit = updatePlaylistUserRequest.CanEdit
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a user from a playlist's users.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <response code="204">User permissions removed from playlist.</response>
|
||||
/// <response code="401">Unauthorized access.</response>
|
||||
/// <response code="404">No playlist or user permissions found.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous operation to delete a user from a playlist's shares.
|
||||
/// The task result contains an <see cref="OkResult"/> indicating success.
|
||||
/// </returns>
|
||||
[HttpDelete("{playlistId}/Users/{userId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> RemoveUserFromPlaylist(
|
||||
[FromRoute, Required] Guid playlistId,
|
||||
[FromRoute, Required] Guid userId)
|
||||
{
|
||||
var callingUserId = User.GetUserId();
|
||||
|
||||
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
|
||||
if (playlist is null)
|
||||
{
|
||||
return NotFound("Playlist not found");
|
||||
}
|
||||
|
||||
var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
|
||||
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId));
|
||||
|
||||
if (!isPermitted)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var share = playlist.Shares.FirstOrDefault(s => s.UserId.Equals(userId));
|
||||
if (share is null)
|
||||
{
|
||||
return NotFound("User permissions not found");
|
||||
}
|
||||
|
||||
await _playlistManager.RemoveUserFromShares(playlistId, callingUserId, share).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds items to a playlist.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <param name="ids">Item id, comma delimited.</param>
|
||||
/// <param name="position">Optional. 0-based index where to place the items or at the end if <c>null</c>.</param>
|
||||
/// <param name="userId">The userId.</param>
|
||||
/// <response code="204">Items added to playlist.</response>
|
||||
/// <response code="403">Access forbidden.</response>
|
||||
/// <response code="404">Playlist not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
|
||||
[HttpPost("{playlistId}/Items")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> AddItemToPlaylist(
|
||||
[FromRoute, Required] Guid playlistId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids,
|
||||
[FromQuery] int? position,
|
||||
[FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value);
|
||||
if (playlist is null)
|
||||
{
|
||||
return NotFound("Playlist not found");
|
||||
}
|
||||
|
||||
var isPermitted = playlist.OwnerUserId.Equals(userId.Value)
|
||||
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(userId.Value));
|
||||
|
||||
if (!isPermitted)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, position, userId.Value).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a playlist item.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="newIndex">The new index.</param>
|
||||
/// <response code="204">Item moved to new index.</response>
|
||||
/// <response code="403">Access forbidden.</response>
|
||||
/// <response code="404">Playlist not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
|
||||
[HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> MoveItem(
|
||||
[FromRoute, Required] string playlistId,
|
||||
[FromRoute, Required] string itemId,
|
||||
[FromRoute, Required] int newIndex)
|
||||
{
|
||||
var callingUserId = User.GetUserId();
|
||||
|
||||
var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(playlistId), callingUserId);
|
||||
if (playlist is null)
|
||||
{
|
||||
return NotFound("Playlist not found");
|
||||
}
|
||||
|
||||
var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
|
||||
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId));
|
||||
|
||||
if (!isPermitted)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex, callingUserId).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes items from a playlist.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <param name="entryIds">The item ids, comma delimited.</param>
|
||||
/// <response code="204">Items removed.</response>
|
||||
/// <response code="403">Access forbidden.</response>
|
||||
/// <response code="404">Playlist not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
|
||||
[HttpDelete("{playlistId}/Items")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> RemoveItemFromPlaylist(
|
||||
[FromRoute, Required] string playlistId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] entryIds)
|
||||
{
|
||||
var callingUserId = User.GetUserId();
|
||||
|
||||
if (!callingUserId.IsEmpty())
|
||||
{
|
||||
var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(playlistId), callingUserId);
|
||||
if (playlist is null)
|
||||
{
|
||||
return NotFound("Playlist not found");
|
||||
}
|
||||
|
||||
var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
|
||||
|| playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId));
|
||||
|
||||
if (!isPermitted)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var isApiKey = User.GetIsApiKey();
|
||||
|
||||
if (!isApiKey)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _playlistManager.RemoveItemFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original items of a playlist.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">The playlist id.</param>
|
||||
/// <param name="userId">User id.</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="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <response code="200">Original playlist returned.</response>
|
||||
/// <response code="404">Access forbidden.</response>
|
||||
/// <response code="404">Playlist not found.</response>
|
||||
/// <returns>The original playlist items.</returns>
|
||||
[HttpGet("{playlistId}/Items")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems(
|
||||
[FromRoute, Required] Guid playlistId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
|
||||
{
|
||||
var callingUserId = userId ?? User.GetUserId();
|
||||
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
|
||||
if (playlist is null)
|
||||
{
|
||||
return NotFound("Playlist not found");
|
||||
}
|
||||
|
||||
var isPermitted = playlist.OpenAccess
|
||||
|| playlist.OwnerUserId.Equals(callingUserId)
|
||||
|| playlist.Shares.Any(s => s.UserId.Equals(callingUserId));
|
||||
|
||||
if (!isPermitted)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var user = _userManager.GetUserById(callingUserId);
|
||||
var items = playlist.GetManageableItems().Where(i => i.Item2.IsVisible(user)).ToArray();
|
||||
var count = items.Length;
|
||||
if (startIndex.HasValue)
|
||||
{
|
||||
items = items.Skip(startIndex.Value).ToArray();
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
items = items.Take(limit.Value).ToArray();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user);
|
||||
for (int index = 0; index < dtos.Count; index++)
|
||||
{
|
||||
dtos[index].PlaylistItemId = items[index].Item1.ItemId?.ToString("N", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
var result = new QueryResult<BaseItemDto>(
|
||||
startIndex,
|
||||
count,
|
||||
dtos);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Playstate controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize]
|
||||
public class PlaystateController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserDataManager _userDataRepository;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly ILogger<PlaystateController> _logger;
|
||||
private readonly ITranscodeManager _transcodeManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlaystateController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
||||
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
|
||||
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
|
||||
public PlaystateController(
|
||||
IUserManager userManager,
|
||||
IUserDataManager userDataRepository,
|
||||
ILibraryManager libraryManager,
|
||||
ISessionManager sessionManager,
|
||||
ILoggerFactory loggerFactory,
|
||||
ITranscodeManager transcodeManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userDataRepository = userDataRepository;
|
||||
_libraryManager = libraryManager;
|
||||
_sessionManager = sessionManager;
|
||||
_logger = loggerFactory.CreateLogger<PlaystateController>();
|
||||
|
||||
_transcodeManager = transcodeManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an item as played for user.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="datePlayed">Optional. The date the item was played.</param>
|
||||
/// <response code="200">Item marked as played.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
|
||||
[HttpPost("UserPlayedItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserItemDataDto?>> MarkPlayedItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext, userId).ConfigureAwait(false);
|
||||
|
||||
var dto = UpdatePlayedStatus(user, item, true, datePlayed);
|
||||
foreach (var additionalUserInfo in session.AdditionalUsers)
|
||||
{
|
||||
var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
|
||||
if (additionalUser is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
UpdatePlayedStatus(additionalUser, item, true, datePlayed);
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an item as played for user.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="datePlayed">Optional. The date the item was played.</param>
|
||||
/// <response code="200">Item marked as played.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
|
||||
[HttpPost("Users/{userId}/PlayedItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public Task<ActionResult<UserItemDataDto?>> MarkPlayedItemLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
|
||||
=> MarkPlayedItem(userId, itemId, datePlayed);
|
||||
|
||||
/// <summary>
|
||||
/// Marks an item as unplayed for user.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Item marked as unplayed.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
|
||||
[HttpDelete("UserPlayedItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<UserItemDataDto?>> MarkUnplayedItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext, userId).ConfigureAwait(false);
|
||||
|
||||
var dto = UpdatePlayedStatus(user, item, false, null);
|
||||
foreach (var additionalUserInfo in session.AdditionalUsers)
|
||||
{
|
||||
var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
|
||||
if (additionalUser is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
UpdatePlayedStatus(additionalUser, item, false, null);
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an item as unplayed for user.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Item marked as unplayed.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns>
|
||||
[HttpDelete("Users/{userId}/PlayedItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public Task<ActionResult<UserItemDataDto?>> MarkUnplayedItemLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
=> MarkUnplayedItem(userId, itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Reports playback has started within a session.
|
||||
/// </summary>
|
||||
/// <param name="playbackStartInfo">The playback start info.</param>
|
||||
/// <response code="204">Playback start recorded.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/Playing")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo)
|
||||
{
|
||||
playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
|
||||
playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports playback progress within a session.
|
||||
/// </summary>
|
||||
/// <param name="playbackProgressInfo">The playback progress info.</param>
|
||||
/// <response code="204">Playback progress recorded.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/Playing/Progress")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo)
|
||||
{
|
||||
playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
|
||||
playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pings a playback session.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">Playback session id.</param>
|
||||
/// <response code="204">Playback session pinged.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/Playing/Ping")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId)
|
||||
{
|
||||
_transcodeManager.PingTranscodingJob(playSessionId, null);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports playback has stopped within a session.
|
||||
/// </summary>
|
||||
/// <param name="playbackStopInfo">The playback stop info.</param>
|
||||
/// <response code="204">Playback stop recorded.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/Playing/Stopped")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo)
|
||||
{
|
||||
_logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
|
||||
if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
|
||||
{
|
||||
await _transcodeManager.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports that a session has begun playing an item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="mediaSourceId">The id of the MediaSource.</param>
|
||||
/// <param name="audioStreamIndex">The audio stream index.</param>
|
||||
/// <param name="subtitleStreamIndex">The subtitle stream index.</param>
|
||||
/// <param name="playMethod">The play method.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="canSeek">Indicates if the client can seek.</param>
|
||||
/// <response code="204">Play start recorded.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("PlayingItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Obsolete("This endpoint is obsolete. Use ReportPlaybackStart instead")]
|
||||
public async Task<ActionResult> OnPlaybackStart(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] PlayMethod? playMethod,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] bool canSeek = false)
|
||||
{
|
||||
var playbackStartInfo = new PlaybackStartInfo
|
||||
{
|
||||
CanSeek = canSeek,
|
||||
ItemId = itemId,
|
||||
MediaSourceId = mediaSourceId,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
PlayMethod = playMethod ?? PlayMethod.Transcode,
|
||||
PlaySessionId = playSessionId,
|
||||
LiveStreamId = liveStreamId
|
||||
};
|
||||
|
||||
playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
|
||||
playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports that a user has begun playing an item.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="mediaSourceId">The id of the MediaSource.</param>
|
||||
/// <param name="audioStreamIndex">The audio stream index.</param>
|
||||
/// <param name="subtitleStreamIndex">The subtitle stream index.</param>
|
||||
/// <param name="playMethod">The play method.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="canSeek">Indicates if the client can seek.</param>
|
||||
/// <response code="204">Play start recorded.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Users/{userId}/PlayingItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
|
||||
public Task<ActionResult> OnPlaybackStartLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] PlayMethod? playMethod,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] bool canSeek = false)
|
||||
=> OnPlaybackStart(itemId, mediaSourceId, audioStreamIndex, subtitleStreamIndex, playMethod, liveStreamId, playSessionId, canSeek);
|
||||
|
||||
/// <summary>
|
||||
/// Reports a session's playback progress.
|
||||
/// </summary>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="mediaSourceId">The id of the MediaSource.</param>
|
||||
/// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="audioStreamIndex">The audio stream index.</param>
|
||||
/// <param name="subtitleStreamIndex">The subtitle stream index.</param>
|
||||
/// <param name="volumeLevel">Scale of 0-100.</param>
|
||||
/// <param name="playMethod">The play method.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="repeatMode">The repeat mode.</param>
|
||||
/// <param name="isPaused">Indicates if the player is paused.</param>
|
||||
/// <param name="isMuted">Indicates if the player is muted.</param>
|
||||
/// <response code="204">Play progress recorded.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("PlayingItems/{itemId}/Progress")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Obsolete("This endpoint is obsolete. Use ReportPlaybackProgress instead")]
|
||||
public async Task<ActionResult> OnPlaybackProgress(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] long? positionTicks,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] int? volumeLevel,
|
||||
[FromQuery] PlayMethod? playMethod,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] RepeatMode? repeatMode,
|
||||
[FromQuery] bool isPaused = false,
|
||||
[FromQuery] bool isMuted = false)
|
||||
{
|
||||
var playbackProgressInfo = new PlaybackProgressInfo
|
||||
{
|
||||
ItemId = itemId,
|
||||
PositionTicks = positionTicks,
|
||||
IsMuted = isMuted,
|
||||
IsPaused = isPaused,
|
||||
MediaSourceId = mediaSourceId,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
VolumeLevel = volumeLevel,
|
||||
PlayMethod = playMethod ?? PlayMethod.Transcode,
|
||||
PlaySessionId = playSessionId,
|
||||
LiveStreamId = liveStreamId,
|
||||
RepeatMode = repeatMode ?? RepeatMode.RepeatNone
|
||||
};
|
||||
|
||||
playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
|
||||
playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports a user's playback progress.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="mediaSourceId">The id of the MediaSource.</param>
|
||||
/// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="audioStreamIndex">The audio stream index.</param>
|
||||
/// <param name="subtitleStreamIndex">The subtitle stream index.</param>
|
||||
/// <param name="volumeLevel">Scale of 0-100.</param>
|
||||
/// <param name="playMethod">The play method.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="repeatMode">The repeat mode.</param>
|
||||
/// <param name="isPaused">Indicates if the player is paused.</param>
|
||||
/// <param name="isMuted">Indicates if the player is muted.</param>
|
||||
/// <response code="204">Play progress recorded.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
|
||||
public Task<ActionResult> OnPlaybackProgressLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] long? positionTicks,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] int? volumeLevel,
|
||||
[FromQuery] PlayMethod? playMethod,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] RepeatMode? repeatMode,
|
||||
[FromQuery] bool isPaused = false,
|
||||
[FromQuery] bool isMuted = false)
|
||||
=> OnPlaybackProgress(itemId, mediaSourceId, positionTicks, audioStreamIndex, subtitleStreamIndex, volumeLevel, playMethod, liveStreamId, playSessionId, repeatMode, isPaused, isMuted);
|
||||
|
||||
/// <summary>
|
||||
/// Reports that a session has stopped playing an item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="mediaSourceId">The id of the MediaSource.</param>
|
||||
/// <param name="nextMediaType">The next media type that will play.</param>
|
||||
/// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <response code="204">Playback stop recorded.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpDelete("PlayingItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Obsolete("This endpoint is obsolete. Use ReportPlaybackStop instead")]
|
||||
public async Task<ActionResult> OnPlaybackStopped(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? nextMediaType,
|
||||
[FromQuery] long? positionTicks,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] string? playSessionId)
|
||||
{
|
||||
var playbackStopInfo = new PlaybackStopInfo
|
||||
{
|
||||
ItemId = itemId,
|
||||
PositionTicks = positionTicks,
|
||||
MediaSourceId = mediaSourceId,
|
||||
PlaySessionId = playSessionId,
|
||||
LiveStreamId = liveStreamId,
|
||||
NextMediaType = nextMediaType
|
||||
};
|
||||
|
||||
_logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
|
||||
if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
|
||||
{
|
||||
await _transcodeManager.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports that a user has stopped playing an item.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="mediaSourceId">The id of the MediaSource.</param>
|
||||
/// <param name="nextMediaType">The next media type that will play.</param>
|
||||
/// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <response code="204">Playback stop recorded.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpDelete("Users/{userId}/PlayingItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
|
||||
public Task<ActionResult> OnPlaybackStoppedLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? nextMediaType,
|
||||
[FromQuery] long? positionTicks,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] string? playSessionId)
|
||||
=> OnPlaybackStopped(itemId, mediaSourceId, nextMediaType, positionTicks, liveStreamId, playSessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the played status.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="wasPlayed">if set to <c>true</c> [was played].</param>
|
||||
/// <param name="datePlayed">The date played.</param>
|
||||
/// <returns>Task.</returns>
|
||||
private UserItemDataDto? UpdatePlayedStatus(User user, BaseItem item, bool wasPlayed, DateTime? datePlayed)
|
||||
{
|
||||
if (wasPlayed)
|
||||
{
|
||||
item.MarkPlayed(user, datePlayed, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
item.MarkUnplayed(user);
|
||||
}
|
||||
|
||||
return _userDataRepository.GetUserDataDto(item, user);
|
||||
}
|
||||
|
||||
private PlayMethod ValidatePlayMethod(PlayMethod method, string? playSessionId)
|
||||
{
|
||||
if (method == PlayMethod.Transcode)
|
||||
{
|
||||
var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodeManager.GetTranscodingJob(playSessionId);
|
||||
if (job is null)
|
||||
{
|
||||
return PlayMethod.DirectPlay;
|
||||
}
|
||||
}
|
||||
|
||||
return method;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Common.Updates;
|
||||
using MediaBrowser.Model.Net;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Plugins controller.
|
||||
/// </summary>
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public class PluginsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IInstallationManager _installationManager;
|
||||
private readonly IPluginManager _pluginManager;
|
||||
private readonly JsonSerializerOptions _serializerOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PluginsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
|
||||
/// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param>
|
||||
public PluginsController(
|
||||
IInstallationManager installationManager,
|
||||
IPluginManager pluginManager)
|
||||
{
|
||||
_installationManager = installationManager;
|
||||
_pluginManager = pluginManager;
|
||||
_serializerOptions = JsonDefaults.Options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of currently installed plugins.
|
||||
/// </summary>
|
||||
/// <response code="200">Installed plugins returned.</response>
|
||||
/// <returns>List of currently installed plugins.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<PluginInfo>> GetPlugins()
|
||||
{
|
||||
return Ok(_pluginManager.Plugins
|
||||
.OrderBy(p => p.Name)
|
||||
.Select(p => p.GetPluginInfo()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables a disabled plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">Plugin id.</param>
|
||||
/// <param name="version">Plugin version.</param>
|
||||
/// <response code="204">Plugin enabled.</response>
|
||||
/// <response code="404">Plugin not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
|
||||
[HttpPost("{pluginId}/{version}/Enable")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
|
||||
{
|
||||
var plugin = _pluginManager.GetPlugin(pluginId, version);
|
||||
if (plugin is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_pluginManager.EnablePlugin(plugin);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disable a plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">Plugin id.</param>
|
||||
/// <param name="version">Plugin version.</param>
|
||||
/// <response code="204">Plugin disabled.</response>
|
||||
/// <response code="404">Plugin not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
|
||||
[HttpPost("{pluginId}/{version}/Disable")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
|
||||
{
|
||||
var plugin = _pluginManager.GetPlugin(pluginId, version);
|
||||
if (plugin is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_pluginManager.DisablePlugin(plugin);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uninstalls a plugin by version.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">Plugin id.</param>
|
||||
/// <param name="version">Plugin version.</param>
|
||||
/// <response code="204">Plugin uninstalled.</response>
|
||||
/// <response code="404">Plugin not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
|
||||
[HttpDelete("{pluginId}/{version}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
|
||||
{
|
||||
var plugin = _pluginManager.GetPlugin(pluginId, version);
|
||||
if (plugin is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_installationManager.UninstallPlugin(plugin);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uninstalls a plugin.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">Plugin id.</param>
|
||||
/// <response code="204">Plugin uninstalled.</response>
|
||||
/// <response code="404">Plugin not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
|
||||
[HttpDelete("{pluginId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Obsolete("Please use the UninstallPluginByVersion API.")]
|
||||
public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId)
|
||||
{
|
||||
// If no version is given, return the current instance.
|
||||
var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId)).ToList();
|
||||
|
||||
// Select the un-instanced one first.
|
||||
var plugin = plugins.FirstOrDefault(p => p.Instance is null) ?? plugins.MinBy(p => p.Manifest.Status);
|
||||
|
||||
if (plugin is not null)
|
||||
{
|
||||
_installationManager.UninstallPlugin(plugin);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
/// <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")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId)
|
||||
{
|
||||
var plugin = _pluginManager.GetPlugin(pluginId);
|
||||
if (plugin?.Instance is IHasPluginConfiguration configPlugin)
|
||||
{
|
||||
return configPlugin.Configuration;
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates plugin configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Accepts plugin configuration as JSON body.
|
||||
/// </remarks>
|
||||
/// <param name="pluginId">Plugin id.</param>
|
||||
/// <response code="204">Plugin configuration updated.</response>
|
||||
/// <response code="404">Plugin not found or plugin does not have configuration.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
|
||||
[HttpPost("{pluginId}/Configuration")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId)
|
||||
{
|
||||
var plugin = _pluginManager.GetPlugin(pluginId);
|
||||
if (plugin?.Instance is not IHasPluginConfiguration configPlugin)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (configuration is not null)
|
||||
{
|
||||
configPlugin.UpdateConfiguration(configuration);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a plugin's image.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">Plugin id.</param>
|
||||
/// <param name="version">Plugin version.</param>
|
||||
/// <response code="200">Plugin image returned.</response>
|
||||
/// <returns>Plugin's image.</returns>
|
||||
[HttpGet("{pluginId}/{version}/Image")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesImageFile]
|
||||
[AllowAnonymous]
|
||||
public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
|
||||
{
|
||||
var plugin = _pluginManager.GetPlugin(pluginId, version);
|
||||
if (plugin is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty);
|
||||
if (plugin.Manifest.ImagePath is null || !System.IO.File.Exists(imagePath))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
Response.Headers.ContentDisposition = "attachment";
|
||||
|
||||
imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath);
|
||||
return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a plugin's manifest.
|
||||
/// </summary>
|
||||
/// <param name="pluginId">Plugin id.</param>
|
||||
/// <response code="204">Plugin manifest returned.</response>
|
||||
/// <response code="404">Plugin not found.</response>
|
||||
/// <returns>A <see cref="PluginManifest"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
|
||||
[HttpPost("{pluginId}/Manifest")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId)
|
||||
{
|
||||
var plugin = _pluginManager.GetPlugin(pluginId);
|
||||
|
||||
if (plugin is not null)
|
||||
{
|
||||
return plugin.Manifest;
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.QuickConnect;
|
||||
using MediaBrowser.Model.QuickConnect;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Quick connect controller.
|
||||
/// </summary>
|
||||
public class QuickConnectController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IQuickConnect _quickConnect;
|
||||
private readonly IAuthorizationContext _authContext;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="QuickConnectController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param>
|
||||
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
|
||||
public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext)
|
||||
{
|
||||
_quickConnect = quickConnect;
|
||||
_authContext = authContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current quick connect state.
|
||||
/// </summary>
|
||||
/// <response code="200">Quick connect state returned.</response>
|
||||
/// <returns>Whether Quick Connect is enabled on the server or not.</returns>
|
||||
[HttpGet("Enabled")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<bool> GetQuickConnectEnabled()
|
||||
{
|
||||
return _quickConnect.IsEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initiate a new quick connect request.
|
||||
/// </summary>
|
||||
/// <response code="200">Quick connect request successfully created.</response>
|
||||
/// <response code="401">Quick connect is not active on this server.</response>
|
||||
/// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns>
|
||||
[HttpPost("Initiate")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect()
|
||||
{
|
||||
try
|
||||
{
|
||||
var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
|
||||
return _quickConnect.TryConnect(auth);
|
||||
}
|
||||
catch (AuthenticationException)
|
||||
{
|
||||
return Unauthorized("Quick connect is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve authentication information.
|
||||
/// </summary>
|
||||
/// <param name="secret">Secret previously returned from the Initiate endpoint.</param>
|
||||
/// <response code="200">Quick connect result returned.</response>
|
||||
/// <response code="404">Unknown quick connect secret.</response>
|
||||
/// <returns>An updated <see cref="QuickConnectResult"/>.</returns>
|
||||
[HttpGet("Connect")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<QuickConnectResult> GetQuickConnectState([FromQuery, Required] string secret)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _quickConnect.CheckRequestStatus(secret);
|
||||
}
|
||||
catch (ResourceNotFoundException)
|
||||
{
|
||||
return NotFound("Unknown secret");
|
||||
}
|
||||
catch (AuthenticationException)
|
||||
{
|
||||
return Unauthorized("Quick connect is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes a pending quick connect request.
|
||||
/// </summary>
|
||||
/// <param name="code">Quick connect code to authorize.</param>
|
||||
/// <param name="userId">The user the authorize. Access to the requested user is required.</param>
|
||||
/// <response code="200">Quick connect result authorized successfully.</response>
|
||||
/// <response code="403">Unknown user id.</response>
|
||||
/// <returns>Boolean indicating if the authorization was successful.</returns>
|
||||
[HttpPost("Authorize")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
|
||||
try
|
||||
{
|
||||
return await _quickConnect.AuthorizeRequest(userId.Value, code).ConfigureAwait(false);
|
||||
}
|
||||
catch (AuthenticationException)
|
||||
{
|
||||
return Unauthorized("Quick connect is disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Providers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Remote Images Controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
public class RemoteImageController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IProviderManager _providerManager;
|
||||
private readonly IServerApplicationPaths _applicationPaths;
|
||||
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="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public RemoteImageController(
|
||||
IProviderManager providerManager,
|
||||
IServerApplicationPaths applicationPaths,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_providerManager = providerManager;
|
||||
_applicationPaths = applicationPaths;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets available remote images for an item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">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("Items/{itemId}/RemoteImages")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RemoteImageResult>> GetRemoteImages(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] ImageType? type,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? providerName,
|
||||
[FromQuery] bool includeAllLanguages = false)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var images = await _providerManager.GetAvailableRemoteImages(
|
||||
item,
|
||||
new RemoteImageQuery(providerName ?? string.Empty)
|
||||
{
|
||||
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="itemId">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("Items/{itemId}/RemoteImages/Providers")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(_providerManager.GetRemoteImageProviderInfo(item));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a remote image for an item.
|
||||
/// </summary>
|
||||
/// <param name="itemId">Item Id.</param>
|
||||
/// <param name="type">The image type.</param>
|
||||
/// <param name="imageUrl">The image url.</param>
|
||||
/// <response code="204">Remote image downloaded.</response>
|
||||
/// <response code="404">Remote image not found.</response>
|
||||
/// <returns>Download status.</returns>
|
||||
[HttpPost("Items/{itemId}/RemoteImages/Download")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DownloadRemoteImage(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery, Required] ImageType type,
|
||||
[FromQuery] string? imageUrl)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Constants;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled Tasks Controller.
|
||||
/// </summary>
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public class ScheduledTasksController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ITaskManager _taskManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScheduledTasksController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param>
|
||||
public ScheduledTasksController(ITaskManager taskManager)
|
||||
{
|
||||
_taskManager = taskManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get tasks.
|
||||
/// </summary>
|
||||
/// <param name="isHidden">Optional filter tasks that are hidden, or not.</param>
|
||||
/// <param name="isEnabled">Optional filter tasks that are enabled, or not.</param>
|
||||
/// <response code="200">Scheduled tasks retrieved.</response>
|
||||
/// <returns>The list of scheduled tasks.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IEnumerable<TaskInfo> GetTasks(
|
||||
[FromQuery] bool? isHidden,
|
||||
[FromQuery] bool? isEnabled)
|
||||
{
|
||||
IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name);
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask)
|
||||
{
|
||||
if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
yield return ScheduledTaskHelpers.GetTaskInfo(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get task by id.
|
||||
/// </summary>
|
||||
/// <param name="taskId">Task Id.</param>
|
||||
/// <response code="200">Task retrieved.</response>
|
||||
/// <response code="404">Task not found.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the task on success, or a <see cref="NotFoundResult"/> if the task could not be found.</returns>
|
||||
[HttpGet("{taskId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<TaskInfo> GetTask([FromRoute, Required] string taskId)
|
||||
{
|
||||
var task = _taskManager.ScheduledTasks.FirstOrDefault(i =>
|
||||
string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (task is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return ScheduledTaskHelpers.GetTaskInfo(task);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start specified task.
|
||||
/// </summary>
|
||||
/// <param name="taskId">Task Id.</param>
|
||||
/// <response code="204">Task started.</response>
|
||||
/// <response code="404">Task not found.</response>
|
||||
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
|
||||
[HttpPost("Running/{taskId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult StartTask([FromRoute, Required] string taskId)
|
||||
{
|
||||
var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
|
||||
o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (task is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_taskManager.Execute(task, new TaskOptions());
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop specified task.
|
||||
/// </summary>
|
||||
/// <param name="taskId">Task Id.</param>
|
||||
/// <response code="204">Task stopped.</response>
|
||||
/// <response code="404">Task not found.</response>
|
||||
/// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
|
||||
[HttpDelete("Running/{taskId}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult StopTask([FromRoute, Required] string taskId)
|
||||
{
|
||||
var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
|
||||
o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (task is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
_taskManager.Cancel(task);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update specified task triggers.
|
||||
/// </summary>
|
||||
/// <param name="taskId">Task Id.</param>
|
||||
/// <param name="triggerInfos">Triggers.</param>
|
||||
/// <response code="204">Task triggers updated.</response>
|
||||
/// <response code="404">Task not found.</response>
|
||||
/// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
|
||||
[HttpPost("{taskId}/Triggers")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult UpdateTask(
|
||||
[FromRoute, Required] string taskId,
|
||||
[FromBody, Required] TaskTriggerInfo[] triggerInfos)
|
||||
{
|
||||
var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
|
||||
o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
|
||||
if (task is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
task.Triggers = triggerInfos;
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Search;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Search controller.
|
||||
/// </summary>
|
||||
[Route("Search/Hints")]
|
||||
[Authorize]
|
||||
public class SearchController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ISearchEngine _searchEngine;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IImageProcessor _imageProcessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
|
||||
public SearchController(
|
||||
ISearchEngine searchEngine,
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService,
|
||||
IImageProcessor imageProcessor)
|
||||
{
|
||||
_searchEngine = searchEngine;
|
||||
_libraryManager = libraryManager;
|
||||
_dtoService = dtoService;
|
||||
_imageProcessor = imageProcessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the search hint result.
|
||||
/// </summary>
|
||||
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="userId">Optional. Supply a user id to search within a user's library or omit to search all.</param>
|
||||
/// <param name="searchTerm">The search term to filter on.</param>
|
||||
/// <param name="includeItemTypes">If specified, only results with the specified item types are returned. This allows multiple, comma delimited.</param>
|
||||
/// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimited.</param>
|
||||
/// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimited.</param>
|
||||
/// <param name="parentId">If specified, only children of the parent are returned.</param>
|
||||
/// <param name="isMovie">Optional filter for movies.</param>
|
||||
/// <param name="isSeries">Optional filter for series.</param>
|
||||
/// <param name="isNews">Optional filter for news.</param>
|
||||
/// <param name="isKids">Optional filter for kids.</param>
|
||||
/// <param name="isSports">Optional filter for sports.</param>
|
||||
/// <param name="includePeople">Optional filter whether to include people.</param>
|
||||
/// <param name="includeMedia">Optional filter whether to include media.</param>
|
||||
/// <param name="includeGenres">Optional filter whether to include genres.</param>
|
||||
/// <param name="includeStudios">Optional filter whether to include studios.</param>
|
||||
/// <param name="includeArtists">Optional filter whether to include artists.</param>
|
||||
/// <response code="200">Search hint returned.</response>
|
||||
/// <returns>An <see cref="SearchHintResult"/> with the results of the search.</returns>
|
||||
[HttpGet]
|
||||
[Description("Gets search hints based on a search term")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<SearchHintResult> GetSearchHints(
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery, Required] string searchTerm,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery] bool? isMovie,
|
||||
[FromQuery] bool? isSeries,
|
||||
[FromQuery] bool? isNews,
|
||||
[FromQuery] bool? isKids,
|
||||
[FromQuery] bool? isSports,
|
||||
[FromQuery] bool includePeople = true,
|
||||
[FromQuery] bool includeMedia = true,
|
||||
[FromQuery] bool includeGenres = true,
|
||||
[FromQuery] bool includeStudios = true,
|
||||
[FromQuery] bool includeArtists = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var result = _searchEngine.GetSearchHints(new SearchQuery
|
||||
{
|
||||
Limit = limit,
|
||||
SearchTerm = searchTerm,
|
||||
IncludeArtists = includeArtists,
|
||||
IncludeGenres = includeGenres,
|
||||
IncludeMedia = includeMedia,
|
||||
IncludePeople = includePeople,
|
||||
IncludeStudios = includeStudios,
|
||||
StartIndex = startIndex,
|
||||
UserId = userId.Value,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
MediaTypes = mediaTypes,
|
||||
ParentId = parentId,
|
||||
|
||||
IsKids = isKids,
|
||||
IsMovie = isMovie,
|
||||
IsNews = isNews,
|
||||
IsSeries = isSeries,
|
||||
IsSports = isSports
|
||||
});
|
||||
|
||||
return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the search hint result.
|
||||
/// </summary>
|
||||
/// <param name="hintInfo">The hint info.</param>
|
||||
/// <returns>SearchHintResult.</returns>
|
||||
private SearchHint GetSearchHintResult(SearchHintInfo hintInfo)
|
||||
{
|
||||
var item = hintInfo.Item;
|
||||
|
||||
var result = new SearchHint
|
||||
{
|
||||
Name = item.Name,
|
||||
IndexNumber = item.IndexNumber,
|
||||
ParentIndexNumber = item.ParentIndexNumber,
|
||||
Id = item.Id,
|
||||
Type = item.GetBaseItemKind(),
|
||||
MediaType = item.MediaType,
|
||||
MatchedTerm = hintInfo.MatchedTerm,
|
||||
RunTimeTicks = item.RunTimeTicks,
|
||||
ProductionYear = item.ProductionYear,
|
||||
ChannelId = item.ChannelId,
|
||||
EndDate = item.EndDate
|
||||
};
|
||||
|
||||
#pragma warning disable CS0618
|
||||
// Kept for compatibility with older clients
|
||||
result.ItemId = result.Id;
|
||||
#pragma warning restore CS0618
|
||||
|
||||
if (item.IsFolder)
|
||||
{
|
||||
result.IsFolder = true;
|
||||
}
|
||||
|
||||
var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary);
|
||||
|
||||
if (primaryImageTag is not null)
|
||||
{
|
||||
result.PrimaryImageTag = primaryImageTag;
|
||||
result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item);
|
||||
}
|
||||
|
||||
SetThumbImageInfo(result, item);
|
||||
SetBackdropImageInfo(result, item);
|
||||
|
||||
switch (item)
|
||||
{
|
||||
case IHasSeries hasSeries:
|
||||
result.Series = hasSeries.SeriesName;
|
||||
break;
|
||||
case LiveTvProgram program:
|
||||
result.StartDate = program.StartDate;
|
||||
break;
|
||||
case Series series:
|
||||
if (series.Status.HasValue)
|
||||
{
|
||||
result.Status = series.Status.Value.ToString();
|
||||
}
|
||||
|
||||
break;
|
||||
case MusicAlbum album:
|
||||
result.Artists = album.Artists;
|
||||
result.AlbumArtist = album.AlbumArtist;
|
||||
break;
|
||||
case Audio song:
|
||||
result.AlbumArtist = song.AlbumArtists?.FirstOrDefault();
|
||||
result.Artists = song.Artists;
|
||||
|
||||
MusicAlbum musicAlbum = song.AlbumEntity;
|
||||
|
||||
if (musicAlbum is not null)
|
||||
{
|
||||
result.Album = musicAlbum.Name;
|
||||
result.AlbumId = musicAlbum.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Album = song.Album;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (!item.ChannelId.IsEmpty())
|
||||
{
|
||||
var channel = _libraryManager.GetItemById<BaseItem>(item.ChannelId);
|
||||
result.ChannelName = channel?.Name;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void SetThumbImageInfo(SearchHint hint, BaseItem item)
|
||||
{
|
||||
var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null;
|
||||
|
||||
if (itemWithImage is null && item is Episode)
|
||||
{
|
||||
itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb);
|
||||
}
|
||||
|
||||
itemWithImage ??= GetParentWithImage<BaseItem>(item, ImageType.Thumb);
|
||||
|
||||
if (itemWithImage is not null)
|
||||
{
|
||||
var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb);
|
||||
|
||||
if (tag is not null)
|
||||
{
|
||||
hint.ThumbImageTag = tag;
|
||||
hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SetBackdropImageInfo(SearchHint hint, BaseItem item)
|
||||
{
|
||||
var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null)
|
||||
?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop);
|
||||
|
||||
if (itemWithImage is not null)
|
||||
{
|
||||
var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop);
|
||||
|
||||
if (tag is not null)
|
||||
{
|
||||
hint.BackdropImageTag = tag;
|
||||
hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private T? GetParentWithImage<T>(BaseItem item, ImageType type)
|
||||
where T : BaseItem
|
||||
{
|
||||
return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The session controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
public class SessionController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SessionController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
|
||||
public SessionController(
|
||||
ISessionManager sessionManager,
|
||||
IUserManager userManager)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of sessions.
|
||||
/// </summary>
|
||||
/// <param name="controllableByUserId">Filter by sessions that a given user is allowed to remote control.</param>
|
||||
/// <param name="deviceId">Filter by device Id.</param>
|
||||
/// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param>
|
||||
/// <response code="200">List of sessions returned.</response>
|
||||
/// <returns>An <see cref="IReadOnlyList{SessionInfoDto}"/> with the available sessions.</returns>
|
||||
[HttpGet("Sessions")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IReadOnlyList<SessionInfoDto>> GetSessions(
|
||||
[FromQuery] Guid? controllableByUserId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] int? activeWithinSeconds)
|
||||
{
|
||||
Guid? controllableUserToCheck = controllableByUserId is null ? null : RequestHelpers.GetUserId(User, controllableByUserId);
|
||||
var result = _sessionManager.GetSessions(
|
||||
User.GetUserId(),
|
||||
deviceId,
|
||||
activeWithinSeconds,
|
||||
controllableUserToCheck,
|
||||
User.GetIsApiKey());
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instructs a session to browse to an item or view.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session Id.</param>
|
||||
/// <param name="itemType">The type of item to browse to.</param>
|
||||
/// <param name="itemId">The Id of the item.</param>
|
||||
/// <param name="itemName">The name of the item.</param>
|
||||
/// <response code="204">Instruction sent to session.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Viewing")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> DisplayContent(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromQuery, Required] BaseItemKind itemType,
|
||||
[FromQuery, Required] string itemId,
|
||||
[FromQuery, Required] string itemName)
|
||||
{
|
||||
var command = new BrowseRequest
|
||||
{
|
||||
ItemId = itemId,
|
||||
ItemName = itemName,
|
||||
ItemType = itemType
|
||||
};
|
||||
|
||||
await _sessionManager.SendBrowseCommand(
|
||||
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
||||
sessionId,
|
||||
command,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instructs a session to play an item.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param>
|
||||
/// <param name="itemIds">The ids of the items to play, comma delimited.</param>
|
||||
/// <param name="startPositionTicks">The starting position of the first item.</param>
|
||||
/// <param name="mediaSourceId">Optional. The media source id.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to play.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to play.</param>
|
||||
/// <param name="startIndex">Optional. The start index.</param>
|
||||
/// <response code="204">Instruction sent to session.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Playing")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> Play(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromQuery, Required] PlayCommand playCommand,
|
||||
[FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] itemIds,
|
||||
[FromQuery] long? startPositionTicks,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] int? startIndex)
|
||||
{
|
||||
var playRequest = new PlayRequest
|
||||
{
|
||||
ItemIds = itemIds,
|
||||
StartPositionTicks = startPositionTicks,
|
||||
PlayCommand = playCommand,
|
||||
MediaSourceId = mediaSourceId,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
StartIndex = startIndex
|
||||
};
|
||||
|
||||
await _sessionManager.SendPlayCommand(
|
||||
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
||||
sessionId,
|
||||
playRequest,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a playstate command to a client.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="command">The <see cref="PlaystateCommand"/>.</param>
|
||||
/// <param name="seekPositionTicks">The optional position ticks.</param>
|
||||
/// <param name="controllingUserId">The optional controlling user id.</param>
|
||||
/// <response code="204">Playstate command sent to session.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Playing/{command}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> SendPlaystateCommand(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromRoute, Required] PlaystateCommand command,
|
||||
[FromQuery] long? seekPositionTicks,
|
||||
[FromQuery] string? controllingUserId)
|
||||
{
|
||||
await _sessionManager.SendPlaystateCommand(
|
||||
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
||||
sessionId,
|
||||
new PlaystateRequest()
|
||||
{
|
||||
Command = command,
|
||||
ControllingUserId = controllingUserId,
|
||||
SeekPositionTicks = seekPositionTicks,
|
||||
},
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a system command to a client.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="command">The command to send.</param>
|
||||
/// <response code="204">System command sent to session.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/System/{command}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> SendSystemCommand(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromRoute, Required] GeneralCommandType command)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var generalCommand = new GeneralCommand
|
||||
{
|
||||
Name = command,
|
||||
ControllingUserId = currentSession.UserId
|
||||
};
|
||||
|
||||
await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a general command to a client.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="command">The command to send.</param>
|
||||
/// <response code="204">General command sent to session.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Command/{command}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> SendGeneralCommand(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromRoute, Required] GeneralCommandType command)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
|
||||
var generalCommand = new GeneralCommand
|
||||
{
|
||||
Name = command,
|
||||
ControllingUserId = currentSession.UserId
|
||||
};
|
||||
|
||||
await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a full general command to a client.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="command">The <see cref="GeneralCommand"/>.</param>
|
||||
/// <response code="204">Full general command sent to session.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Command")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> SendFullGeneralCommand(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromBody, Required] GeneralCommand command)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
command.ControllingUserId = currentSession.UserId;
|
||||
|
||||
await _sessionManager.SendGeneralCommand(
|
||||
currentSession.Id,
|
||||
sessionId,
|
||||
command,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a command to a client to display a message to the user.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="command">The <see cref="MessageCommand" /> object containing Header, Message Text, and TimeoutMs.</param>
|
||||
/// <response code="204">Message sent.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Message")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> SendMessageCommand(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromBody, Required] MessageCommand command)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.Header))
|
||||
{
|
||||
command.Header = "Message from Server";
|
||||
}
|
||||
|
||||
await _sessionManager.SendMessageCommand(
|
||||
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
||||
sessionId,
|
||||
command,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an additional user to a session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <response code="204">User added to session.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/User/{userId}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult AddUserToSession(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromRoute, Required] Guid userId)
|
||||
{
|
||||
_sessionManager.AddAdditionalUser(sessionId, userId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an additional user from a session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <response code="204">User removed from session.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpDelete("Sessions/{sessionId}/User/{userId}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult RemoveUserFromSession(
|
||||
[FromRoute, Required] string sessionId,
|
||||
[FromRoute, Required] Guid userId)
|
||||
{
|
||||
_sessionManager.RemoveAdditionalUser(sessionId, userId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates capabilities for a device.
|
||||
/// </summary>
|
||||
/// <param name="id">The session id.</param>
|
||||
/// <param name="playableMediaTypes">A list of playable media types, comma delimited. Audio, Video, Book, Photo.</param>
|
||||
/// <param name="supportedCommands">A list of supported remote control commands, comma delimited.</param>
|
||||
/// <param name="supportsMediaControl">Determines whether media can be played remotely..</param>
|
||||
/// <param name="supportsPersistentIdentifier">Determines whether the device supports a unique identifier.</param>
|
||||
/// <response code="204">Capabilities posted.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/Capabilities")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> PostCapabilities(
|
||||
[FromQuery] string? id,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] playableMediaTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] GeneralCommandType[] supportedCommands,
|
||||
[FromQuery] bool supportsMediaControl = false,
|
||||
[FromQuery] bool supportsPersistentIdentifier = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_sessionManager.ReportCapabilities(id, new ClientCapabilities
|
||||
{
|
||||
PlayableMediaTypes = playableMediaTypes,
|
||||
SupportedCommands = supportedCommands,
|
||||
SupportsMediaControl = supportsMediaControl,
|
||||
SupportsPersistentIdentifier = supportsPersistentIdentifier
|
||||
});
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates capabilities for a device.
|
||||
/// </summary>
|
||||
/// <param name="id">The session id.</param>
|
||||
/// <param name="capabilities">The <see cref="ClientCapabilities"/>.</param>
|
||||
/// <response code="204">Capabilities updated.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/Capabilities/Full")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> PostFullCapabilities(
|
||||
[FromQuery] string? id,
|
||||
[FromBody, Required] ClientCapabilitiesDto capabilities)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports that a session is viewing an item.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session id.</param>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <response code="204">Session reported to server.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/Viewing")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> ReportViewing(
|
||||
[FromQuery] string? sessionId,
|
||||
[FromQuery, Required] string? itemId)
|
||||
{
|
||||
string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
|
||||
_sessionManager.ReportNowViewingItem(session, itemId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports that a session has ended.
|
||||
/// </summary>
|
||||
/// <response code="204">Session end reported to server.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Sessions/Logout")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> ReportSessionEnded()
|
||||
{
|
||||
await _sessionManager.Logout(User.GetToken()).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all auth providers.
|
||||
/// </summary>
|
||||
/// <response code="200">Auth providers retrieved.</response>
|
||||
/// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns>
|
||||
[HttpGet("Auth/Providers")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders()
|
||||
{
|
||||
return _userManager.GetAuthenticationProviders();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all password reset providers.
|
||||
/// </summary>
|
||||
/// <response code="200">Password reset providers retrieved.</response>
|
||||
/// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns>
|
||||
[HttpGet("Auth/PasswordResetProviders")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders()
|
||||
{
|
||||
return _userManager.GetPasswordResetProviders();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Models.StartupDtos;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The startup wizard controller.
|
||||
/// </summary>
|
||||
[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
|
||||
public class StartupController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StartupController" /> class.
|
||||
/// </summary>
|
||||
/// <param name="config">The server configuration manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
public StartupController(IServerConfigurationManager config, IUserManager userManager)
|
||||
{
|
||||
_config = config;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the startup wizard.
|
||||
/// </summary>
|
||||
/// <response code="204">Startup wizard completed.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Complete")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult CompleteWizard()
|
||||
{
|
||||
_config.Configuration.IsStartupWizardCompleted = true;
|
||||
_config.SaveConfiguration();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the initial startup wizard configuration.
|
||||
/// </summary>
|
||||
/// <response code="200">Initial startup wizard configuration retrieved.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns>
|
||||
[HttpGet("Configuration")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<StartupConfigurationDto> GetStartupConfiguration()
|
||||
{
|
||||
return new StartupConfigurationDto
|
||||
{
|
||||
ServerName = _config.Configuration.ServerName,
|
||||
UICulture = _config.Configuration.UICulture,
|
||||
MetadataCountryCode = _config.Configuration.MetadataCountryCode,
|
||||
PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the initial startup wizard configuration.
|
||||
/// </summary>
|
||||
/// <param name="startupConfiguration">The updated startup configuration.</param>
|
||||
/// <response code="204">Configuration saved.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Configuration")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
|
||||
{
|
||||
_config.Configuration.ServerName = startupConfiguration.ServerName ?? string.Empty;
|
||||
_config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty;
|
||||
_config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty;
|
||||
_config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty;
|
||||
_config.SaveConfiguration();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets remote access and UPnP.
|
||||
/// </summary>
|
||||
/// <param name="startupRemoteAccessDto">The startup remote access dto.</param>
|
||||
/// <response code="204">Configuration saved.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("RemoteAccess")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto)
|
||||
{
|
||||
NetworkConfiguration settings = _config.GetNetworkConfiguration();
|
||||
settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess;
|
||||
_config.SaveConfiguration(NetworkConfigurationStore.StoreKey, settings);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first user.
|
||||
/// </summary>
|
||||
/// <response code="200">Initial user retrieved.</response>
|
||||
/// <returns>The first user.</returns>
|
||||
[HttpGet("User")]
|
||||
[HttpGet("FirstUser", Name = "GetFirstUser_2")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<StartupUserDto> GetFirstUser()
|
||||
{
|
||||
// TODO: Remove this method when startup wizard no longer requires an existing user.
|
||||
await _userManager.InitializeAsync().ConfigureAwait(false);
|
||||
var user = _userManager.Users.First();
|
||||
return new StartupUserDto
|
||||
{
|
||||
Name = user.Username
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the user name and password.
|
||||
/// </summary>
|
||||
/// <param name="startupUserDto">The DTO containing username and password.</param>
|
||||
/// <response code="204">Updated user name and password.</response>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> that represents the asynchronous update operation.
|
||||
/// The task result contains a <see cref="NoContentResult"/> indicating success.
|
||||
/// </returns>
|
||||
[HttpPost("User")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
|
||||
{
|
||||
var user = _userManager.Users.First();
|
||||
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
|
||||
{
|
||||
return BadRequest("Password must not be empty");
|
||||
}
|
||||
|
||||
if (startupUserDto.Name is not null)
|
||||
{
|
||||
user.Username = startupUserDto.Name;
|
||||
}
|
||||
|
||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrEmpty(startupUserDto.Password))
|
||||
{
|
||||
await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Studios controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class StudiosController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StudiosController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
public StudiosController(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDtoService dtoService)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dtoService = dtoService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all studios from a given item, folder, or the entire library.
|
||||
/// </summary>
|
||||
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="searchTerm">Optional. Search term.</param>
|
||||
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
|
||||
/// <param name="enableUserData">Optional, include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
|
||||
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
|
||||
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
|
||||
/// <param name="enableImages">Optional, include image information in output.</param>
|
||||
/// <param name="enableTotalRecordCount">Total record count.</param>
|
||||
/// <response code="200">Studios returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the studios.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetStudios(
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? searchTerm,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery] bool? isFavorite,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? nameStartsWithOrGreater,
|
||||
[FromQuery] string? nameStartsWith,
|
||||
[FromQuery] string? nameLessThan,
|
||||
[FromQuery] bool? enableImages = true,
|
||||
[FromQuery] bool enableTotalRecordCount = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
var parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
StartIndex = startIndex,
|
||||
Limit = limit,
|
||||
IsFavorite = isFavorite,
|
||||
NameLessThan = nameLessThan,
|
||||
NameStartsWith = nameStartsWith,
|
||||
NameStartsWithOrGreater = nameStartsWithOrGreater,
|
||||
DtoOptions = dtoOptions,
|
||||
SearchTerm = searchTerm,
|
||||
EnableTotalRecordCount = enableTotalRecordCount
|
||||
};
|
||||
|
||||
if (parentId.HasValue)
|
||||
{
|
||||
if (parentItem is Folder)
|
||||
{
|
||||
query.AncestorIds = new[] { parentId.Value };
|
||||
}
|
||||
else
|
||||
{
|
||||
query.ItemIds = new[] { parentId.Value };
|
||||
}
|
||||
}
|
||||
|
||||
var result = _libraryManager.GetStudios(query);
|
||||
var shouldIncludeItemTypes = includeItemTypes.Length != 0;
|
||||
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a studio by name.
|
||||
/// </summary>
|
||||
/// <param name="name">Studio name.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <response code="200">Studio returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the studio.</returns>
|
||||
[HttpGet("{name}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
var item = _libraryManager.GetStudio(name);
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.Models.SubtitleDtos;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
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 MediaBrowser.Model.Subtitles;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Subtitle controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
public class SubtitleController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
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 ILogger<SubtitleController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SubtitleController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <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="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
|
||||
public SubtitleController(
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
ILibraryManager libraryManager,
|
||||
ISubtitleManager subtitleManager,
|
||||
ISubtitleEncoder subtitleEncoder,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IProviderManager providerManager,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<SubtitleController> logger)
|
||||
{
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_libraryManager = libraryManager;
|
||||
_subtitleManager = subtitleManager;
|
||||
_subtitleEncoder = subtitleEncoder;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_providerManager = providerManager;
|
||||
_fileSystem = fileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an external subtitle file.
|
||||
/// </summary>
|
||||
/// <param name="itemId">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/{itemId}/Subtitles/{index}")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DeleteSubtitle(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] int index)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await _subtitleManager.DeleteSubtitles(item, index).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search remote subtitles.
|
||||
/// </summary>
|
||||
/// <param name="itemId">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>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns>
|
||||
[HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")]
|
||||
[Authorize(Policy = Policies.SubtitleManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] string language,
|
||||
[FromQuery] bool? isPerfectMatch)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return await _subtitleManager.SearchSubtitles(item, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a remote subtitle.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="subtitleId">The subtitle id.</param>
|
||||
/// <response code="204">Subtitle downloaded.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")]
|
||||
[Authorize(Policy = Policies.SubtitleManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DownloadRemoteSubtitles(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] string subtitleId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _subtitleManager.DownloadSubtitles(item, subtitleId, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_providerManager.QueueRefresh(item.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="subtitleId">The item id.</param>
|
||||
/// <response code="200">File returned.</response>
|
||||
/// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns>
|
||||
[HttpGet("Providers/Subtitles/Subtitles/{subtitleId}")]
|
||||
[Authorize(Policy = Policies.SubtitleManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Produces(MediaTypeNames.Application.Octet)]
|
||||
[ProducesFile("text/*")]
|
||||
public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string subtitleId)
|
||||
{
|
||||
var result = await _subtitleManager.GetRemoteSubtitles(subtitleId, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets subtitles in a specified format.
|
||||
/// </summary>
|
||||
/// <param name="routeItemId">The (route) item id.</param>
|
||||
/// <param name="routeMediaSourceId">The (route) media source id.</param>
|
||||
/// <param name="routeIndex">The (route) subtitle stream index.</param>
|
||||
/// <param name="routeFormat">The (route) format of the returned subtitle.</param>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <param name="index">The subtitle stream index.</param>
|
||||
/// <param name="format">The format of the returned subtitle.</param>
|
||||
/// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
|
||||
/// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
|
||||
/// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
|
||||
/// <param name="startPositionTicks">The start position of the subtitle in ticks.</param>
|
||||
/// <response code="200">File returned.</response>
|
||||
/// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
|
||||
[HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesFile("text/*")]
|
||||
public async Task<ActionResult> GetSubtitle(
|
||||
[FromRoute, Required] Guid routeItemId,
|
||||
[FromRoute, Required] string routeMediaSourceId,
|
||||
[FromRoute, Required] int routeIndex,
|
||||
[FromRoute, Required] string routeFormat,
|
||||
[FromQuery, ParameterObsolete] Guid? itemId,
|
||||
[FromQuery, ParameterObsolete] string? mediaSourceId,
|
||||
[FromQuery, ParameterObsolete] int? index,
|
||||
[FromQuery, ParameterObsolete] string? format,
|
||||
[FromQuery] long? endPositionTicks,
|
||||
[FromQuery] bool copyTimestamps = false,
|
||||
[FromQuery] bool addVttTimeMap = false,
|
||||
[FromQuery] long startPositionTicks = 0)
|
||||
{
|
||||
// Set parameters to route value if not provided via query.
|
||||
itemId ??= routeItemId;
|
||||
mediaSourceId ??= routeMediaSourceId;
|
||||
index ??= routeIndex;
|
||||
format ??= routeFormat;
|
||||
|
||||
if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
format = "json";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(format))
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Video>(itemId.Value);
|
||||
|
||||
var idString = itemId.Value.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);
|
||||
|
||||
return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path));
|
||||
}
|
||||
|
||||
if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
|
||||
{
|
||||
Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
|
||||
await using (stream.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(
|
||||
itemId.Value,
|
||||
mediaSourceId,
|
||||
index.Value,
|
||||
format,
|
||||
startPositionTicks,
|
||||
endPositionTicks,
|
||||
copyTimestamps).ConfigureAwait(false),
|
||||
MimeTypes.GetMimeType("file." + format));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets subtitles in a specified format.
|
||||
/// </summary>
|
||||
/// <param name="routeItemId">The (route) item id.</param>
|
||||
/// <param name="routeMediaSourceId">The (route) media source id.</param>
|
||||
/// <param name="routeIndex">The (route) subtitle stream index.</param>
|
||||
/// <param name="routeStartPositionTicks">The (route) start position of the subtitle in ticks.</param>
|
||||
/// <param name="routeFormat">The (route) format of the returned subtitle.</param>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <param name="index">The subtitle stream index.</param>
|
||||
/// <param name="startPositionTicks">The start position of the subtitle in ticks.</param>
|
||||
/// <param name="format">The format of the returned subtitle.</param>
|
||||
/// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
|
||||
/// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
|
||||
/// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
|
||||
/// <response code="200">File returned.</response>
|
||||
/// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
|
||||
[HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesFile("text/*")]
|
||||
public Task<ActionResult> GetSubtitleWithTicks(
|
||||
[FromRoute, Required] Guid routeItemId,
|
||||
[FromRoute, Required] string routeMediaSourceId,
|
||||
[FromRoute, Required] int routeIndex,
|
||||
[FromRoute, Required] long routeStartPositionTicks,
|
||||
[FromRoute, Required] string routeFormat,
|
||||
[FromQuery, ParameterObsolete] Guid? itemId,
|
||||
[FromQuery, ParameterObsolete] string? mediaSourceId,
|
||||
[FromQuery, ParameterObsolete] int? index,
|
||||
[FromQuery, ParameterObsolete] long? startPositionTicks,
|
||||
[FromQuery, ParameterObsolete] string? format,
|
||||
[FromQuery] long? endPositionTicks,
|
||||
[FromQuery] bool copyTimestamps = false,
|
||||
[FromQuery] bool addVttTimeMap = false)
|
||||
{
|
||||
return GetSubtitle(
|
||||
routeItemId,
|
||||
routeMediaSourceId,
|
||||
routeIndex,
|
||||
routeFormat,
|
||||
itemId,
|
||||
mediaSourceId,
|
||||
index,
|
||||
format,
|
||||
endPositionTicks,
|
||||
copyTimestamps,
|
||||
addVttTimeMap,
|
||||
startPositionTicks ?? routeStartPositionTicks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an HLS subtitle playlist.
|
||||
/// </summary>
|
||||
/// <param name="itemId">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>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns>
|
||||
[HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesPlaylistFile]
|
||||
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
|
||||
public async Task<ActionResult> GetSubtitlePlaylist(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] int index,
|
||||
[FromRoute, Required] string mediaSourceId,
|
||||
[FromQuery, Required] int segmentLength)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
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)");
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("#EXTM3U")
|
||||
.Append("#EXT-X-TARGETDURATION:")
|
||||
.Append(segmentLength)
|
||||
.AppendLine()
|
||||
.AppendLine("#EXT-X-VERSION:3")
|
||||
.AppendLine("#EXT-X-MEDIA-SEQUENCE:0")
|
||||
.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
|
||||
|
||||
long positionTicks = 0;
|
||||
|
||||
var accessToken = User.GetToken();
|
||||
|
||||
while (positionTicks < runtime)
|
||||
{
|
||||
var remaining = runtime - positionTicks;
|
||||
var lengthTicks = Math.Min(remaining, segmentLengthTicks);
|
||||
|
||||
builder.Append("#EXTINF:")
|
||||
.Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds)
|
||||
.Append(',')
|
||||
.AppendLine();
|
||||
|
||||
var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks);
|
||||
|
||||
var url = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&ApiKey={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>
|
||||
/// Upload an external subtitle file.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item the subtitle belongs to.</param>
|
||||
/// <param name="body">The request body.</param>
|
||||
/// <response code="204">Subtitle uploaded.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Videos/{itemId}/Subtitles")]
|
||||
[Authorize(Policy = Policies.SubtitleManagement)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UploadSubtitle(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromBody, Required] UploadSubtitleDto body)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(body.Data);
|
||||
var memoryStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
|
||||
await using (memoryStream.ConfigureAwait(false))
|
||||
{
|
||||
using var transform = new FromBase64Transform();
|
||||
var stream = new CryptoStream(memoryStream, transform, CryptoStreamMode.Read);
|
||||
await using (stream.ConfigureAwait(false))
|
||||
{
|
||||
await _subtitleManager.UploadSubtitle(
|
||||
item,
|
||||
new SubtitleResponse
|
||||
{
|
||||
Format = body.Format,
|
||||
Language = body.Language,
|
||||
IsForced = body.IsForced,
|
||||
IsHearingImpaired = body.IsHearingImpaired,
|
||||
Stream = stream
|
||||
}).ConfigureAwait(false);
|
||||
_providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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<BaseItem>(id);
|
||||
|
||||
return _subtitleEncoder.GetSubtitles(
|
||||
item,
|
||||
mediaSourceId,
|
||||
index,
|
||||
format,
|
||||
startPositionTicks,
|
||||
endPositionTicks ?? 0,
|
||||
copyTimestamps,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of available fallback font files.
|
||||
/// </summary>
|
||||
/// <response code="200">Information retrieved.</response>
|
||||
/// <returns>An array of <see cref="FontFile"/> with the available font files.</returns>
|
||||
[HttpGet("FallbackFont/Fonts")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IEnumerable<FontFile> GetFallbackFontList()
|
||||
{
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
var fallbackFontPath = encodingOptions.FallbackFontPath;
|
||||
|
||||
if (!string.IsNullOrEmpty(fallbackFontPath))
|
||||
{
|
||||
var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false);
|
||||
var fontFiles = files
|
||||
.Select(i => new FontFile
|
||||
{
|
||||
Name = i.Name,
|
||||
Size = i.Length,
|
||||
DateCreated = _fileSystem.GetCreationTimeUtc(i),
|
||||
DateModified = _fileSystem.GetLastWriteTimeUtc(i)
|
||||
})
|
||||
.OrderBy(i => i.Size)
|
||||
.ThenBy(i => i.Name)
|
||||
.ThenByDescending(i => i.DateModified)
|
||||
.ThenByDescending(i => i.DateCreated);
|
||||
// max total size 20M
|
||||
const int MaxSize = 20971520;
|
||||
var sizeCounter = 0L;
|
||||
foreach (var fontFile in fontFiles)
|
||||
{
|
||||
sizeCounter += fontFile.Size;
|
||||
if (sizeCounter >= MaxSize)
|
||||
{
|
||||
_logger.LogWarning("Some fonts will not be sent due to size limitations");
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return fontFile;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("The path of fallback font folder has not been set");
|
||||
encodingOptions.EnableFallbackFont = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a fallback font file.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the fallback font file to get.</param>
|
||||
/// <response code="200">Fallback font file retrieved.</response>
|
||||
/// <returns>The fallback font file.</returns>
|
||||
[HttpGet("FallbackFont/Fonts/{name}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesFile("font/*")]
|
||||
public ActionResult GetFallbackFont([FromRoute, Required] string name)
|
||||
{
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
var fallbackFontPath = encodingOptions.FallbackFontPath;
|
||||
|
||||
if (!string.IsNullOrEmpty(fallbackFontPath))
|
||||
{
|
||||
var fontFile = _fileSystem.GetFiles(fallbackFontPath)
|
||||
.First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
var fileSize = fontFile?.Length;
|
||||
|
||||
if (fontFile is not null && fileSize is not null && fileSize > 0)
|
||||
{
|
||||
_logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize);
|
||||
return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName));
|
||||
}
|
||||
|
||||
_logger.LogWarning("The selected font is null or empty");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("The path of fallback font folder has not been set");
|
||||
encodingOptions.EnableFallbackFont = false;
|
||||
}
|
||||
|
||||
// returning HTTP 204 will break the SubtitlesOctopus
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
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>
|
||||
/// The suggestions controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize]
|
||||
public class SuggestionsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SuggestionsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public SuggestionsController(
|
||||
IDtoService dtoService,
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_dtoService = dtoService;
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets suggestions.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="mediaType">The media types.</param>
|
||||
/// <param name="type">The type.</param>
|
||||
/// <param name="startIndex">Optional. The start index.</param>
|
||||
/// <param name="limit">Optional. The limit.</param>
|
||||
/// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
|
||||
/// <response code="200">Suggestions returned.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns>
|
||||
[HttpGet("Items/Suggestions")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaType,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] type,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] bool enableTotalRecordCount = false)
|
||||
{
|
||||
User? user;
|
||||
if (userId.IsNullOrEmpty())
|
||||
{
|
||||
user = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var requestUserId = RequestHelpers.GetUserId(User, userId);
|
||||
user = _userManager.GetUserById(requestUserId);
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
|
||||
{
|
||||
OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) },
|
||||
MediaTypes = mediaType,
|
||||
IncludeItemTypes = type,
|
||||
IsVirtualItem = false,
|
||||
StartIndex = startIndex,
|
||||
Limit = limit,
|
||||
DtoOptions = dtoOptions,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
Recursive = true
|
||||
});
|
||||
|
||||
var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user);
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
startIndex,
|
||||
result.TotalRecordCount,
|
||||
dtoList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets suggestions.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="mediaType">The media types.</param>
|
||||
/// <param name="type">The type.</param>
|
||||
/// <param name="startIndex">Optional. The start index.</param>
|
||||
/// <param name="limit">Optional. The limit.</param>
|
||||
/// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
|
||||
/// <response code="200">Suggestions returned.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns>
|
||||
[HttpGet("Users/{userId}/Suggestions")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetSuggestionsLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaType,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] type,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] bool enableTotalRecordCount = false)
|
||||
=> GetSuggestions(userId, mediaType, type, startIndex, limit, enableTotalRecordCount);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.Models.SyncPlayDtos;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Controller.SyncPlay;
|
||||
using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
|
||||
using MediaBrowser.Controller.SyncPlay.Requests;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The sync play controller.
|
||||
/// </summary>
|
||||
[Authorize(Policy = Policies.SyncPlayHasAccess)]
|
||||
public class SyncPlayController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly ISyncPlayManager _syncPlayManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SyncPlayController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
||||
/// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
public SyncPlayController(
|
||||
ISessionManager sessionManager,
|
||||
ISyncPlayManager syncPlayManager,
|
||||
IUserManager userManager)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
_syncPlayManager = syncPlayManager;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The settings of the new group.</param>
|
||||
/// <response code="204">New group created.</response>
|
||||
/// <returns>An <see cref="GroupInfoDto"/> for the created group.</returns>
|
||||
[HttpPost("New")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Authorize(Policy = Policies.SyncPlayCreateGroup)]
|
||||
public async Task<ActionResult<GroupInfoDto>> SyncPlayCreateGroup(
|
||||
[FromBody, Required] NewGroupRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
|
||||
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Join an existing SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The group to join.</param>
|
||||
/// <response code="204">Group join successful.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Join")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayJoinGroup)]
|
||||
public async Task<ActionResult> SyncPlayJoinGroup(
|
||||
[FromBody, Required] JoinGroupRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new JoinGroupRequest(requestData.GroupId);
|
||||
_syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leave the joined SyncPlay group.
|
||||
/// </summary>
|
||||
/// <response code="204">Group leave successful.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Leave")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayLeaveGroup()
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new LeaveGroupRequest();
|
||||
_syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all SyncPlay groups.
|
||||
/// </summary>
|
||||
/// <response code="200">Groups returned.</response>
|
||||
/// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns>
|
||||
[HttpGet("List")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Authorize(Policy = Policies.SyncPlayJoinGroup)]
|
||||
public async Task<ActionResult<IEnumerable<GroupInfoDto>>> SyncPlayGetGroups()
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new ListGroupsRequest();
|
||||
return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a SyncPlay group by id.
|
||||
/// </summary>
|
||||
/// <param name="id">The id of the group.</param>
|
||||
/// <response code="200">Group returned.</response>
|
||||
/// <returns>An <see cref="GroupInfoDto"/> for the requested group.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Authorize(Policy = Policies.SyncPlayJoinGroup)]
|
||||
public async Task<ActionResult<GroupInfoDto>> SyncPlayGetGroup([FromRoute] Guid id)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var group = _syncPlayManager.GetGroup(currentSession, id);
|
||||
return group is null ? NotFound() : Ok(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to set new playlist in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The new playlist to play in the group.</param>
|
||||
/// <response code="204">Queue update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("SetNewQueue")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlaySetNewQueue(
|
||||
[FromBody, Required] PlayRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new PlayGroupRequest(
|
||||
requestData.PlayingQueue,
|
||||
requestData.PlayingItemPosition,
|
||||
requestData.StartPositionTicks);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to change playlist item in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The new item to play.</param>
|
||||
/// <response code="204">Queue update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("SetPlaylistItem")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlaySetPlaylistItem(
|
||||
[FromBody, Required] SetPlaylistItemRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to remove items from the playlist in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The items to remove.</param>
|
||||
/// <response code="204">Queue update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("RemoveFromPlaylist")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayRemoveFromPlaylist(
|
||||
[FromBody, Required] RemoveFromPlaylistRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds, requestData.ClearPlaylist, requestData.ClearPlayingItem);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to move an item in the playlist in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The new position for the item.</param>
|
||||
/// <response code="204">Queue update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("MovePlaylistItem")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayMovePlaylistItem(
|
||||
[FromBody, Required] MovePlaylistItemRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to queue items to the playlist of a SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The items to add.</param>
|
||||
/// <response code="204">Queue update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Queue")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayQueue(
|
||||
[FromBody, Required] QueueRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request unpause in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <response code="204">Unpause update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Unpause")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayUnpause()
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new UnpauseGroupRequest();
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request pause in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <response code="204">Pause update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Pause")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayPause()
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new PauseGroupRequest();
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request stop in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <response code="204">Stop update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Stop")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayStop()
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new StopGroupRequest();
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request seek in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The new playback position.</param>
|
||||
/// <response code="204">Seek update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Seek")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlaySeek(
|
||||
[FromBody, Required] SeekRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify SyncPlay group that member is buffering.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The player status.</param>
|
||||
/// <response code="204">Group state update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Buffering")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayBuffering(
|
||||
[FromBody, Required] BufferRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new BufferGroupRequest(
|
||||
requestData.When,
|
||||
requestData.PositionTicks,
|
||||
requestData.IsPlaying,
|
||||
requestData.PlaylistItemId);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify SyncPlay group that member is ready for playback.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The player status.</param>
|
||||
/// <response code="204">Group state update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Ready")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayReady(
|
||||
[FromBody, Required] ReadyRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new ReadyGroupRequest(
|
||||
requestData.When,
|
||||
requestData.PositionTicks,
|
||||
requestData.IsPlaying,
|
||||
requestData.PlaylistItemId);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request SyncPlay group to ignore member during group-wait.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The settings to set.</param>
|
||||
/// <response code="204">Member state updated.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("SetIgnoreWait")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlaySetIgnoreWait(
|
||||
[FromBody, Required] IgnoreWaitRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request next item in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The current item information.</param>
|
||||
/// <response code="204">Next item update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("NextItem")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayNextItem(
|
||||
[FromBody, Required] NextItemRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request previous item in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The current item information.</param>
|
||||
/// <response code="204">Previous item update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("PreviousItem")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlayPreviousItem(
|
||||
[FromBody, Required] PreviousItemRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to set repeat mode in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The new repeat mode.</param>
|
||||
/// <response code="204">Play queue update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("SetRepeatMode")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlaySetRepeatMode(
|
||||
[FromBody, Required] SetRepeatModeRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to set shuffle mode in SyncPlay group.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The new shuffle mode.</param>
|
||||
/// <response code="204">Play queue update sent to all group members.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("SetShuffleMode")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.SyncPlayIsInGroup)]
|
||||
public async Task<ActionResult> SyncPlaySetShuffleMode(
|
||||
[FromBody, Required] SetShuffleModeRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update session ping.
|
||||
/// </summary>
|
||||
/// <param name="requestData">The new ping.</param>
|
||||
/// <response code="204">Ping updated.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Ping")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> SyncPlayPing(
|
||||
[FromBody, Required] PingRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new PingGroupRequest(requestData.Ping);
|
||||
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Models.SystemInfoDtos;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Net;
|
||||
using MediaBrowser.Model.System;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The system controller.
|
||||
/// </summary>
|
||||
public class SystemController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILogger<SystemController> _logger;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly INetworkManager _networkManager;
|
||||
private readonly ISystemManager _systemManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SystemController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param>
|
||||
/// <param name="appPaths">Instance of <see cref="IServerApplicationPaths"/> interface.</param>
|
||||
/// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
|
||||
/// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="networkManager">Instance of <see cref="INetworkManager"/> interface.</param>
|
||||
/// <param name="systemManager">Instance of <see cref="ISystemManager"/> interface.</param>
|
||||
public SystemController(
|
||||
ILogger<SystemController> logger,
|
||||
IServerApplicationHost appHost,
|
||||
IServerApplicationPaths appPaths,
|
||||
IFileSystem fileSystem,
|
||||
INetworkManager networkManager,
|
||||
ISystemManager systemManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_appHost = appHost;
|
||||
_appPaths = appPaths;
|
||||
_fileSystem = fileSystem;
|
||||
_networkManager = networkManager;
|
||||
_systemManager = systemManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about the server.
|
||||
/// </summary>
|
||||
/// <response code="200">Information retrieved.</response>
|
||||
/// <response code="403">User does not have permission to retrieve information.</response>
|
||||
/// <returns>A <see cref="SystemInfo"/> with info about the system.</returns>
|
||||
[HttpGet("Info")]
|
||||
[Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public ActionResult<SystemInfo> GetSystemInfo()
|
||||
=> _systemManager.GetSystemInfo(Request);
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about the server.
|
||||
/// </summary>
|
||||
/// <response code="200">Information retrieved.</response>
|
||||
/// <response code="403">User does not have permission to retrieve information.</response>
|
||||
/// <returns>A <see cref="SystemInfo"/> with info about the system.</returns>
|
||||
[HttpGet("Info/Storage")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public ActionResult<SystemStorageDto> GetSystemStorage()
|
||||
=> Ok(SystemStorageDto.FromSystemStorageInfo(_systemManager.GetSystemStorageInfo()));
|
||||
|
||||
/// <summary>
|
||||
/// Gets public information about the server.
|
||||
/// </summary>
|
||||
/// <response code="200">Information retrieved.</response>
|
||||
/// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns>
|
||||
[HttpGet("Info/Public")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<PublicSystemInfo> GetPublicSystemInfo()
|
||||
=> _systemManager.GetPublicSystemInfo(Request);
|
||||
|
||||
/// <summary>
|
||||
/// Pings the system.
|
||||
/// </summary>
|
||||
/// <response code="200">Information retrieved.</response>
|
||||
/// <returns>The server name.</returns>
|
||||
[HttpGet("Ping", Name = "GetPingSystem")]
|
||||
[HttpPost("Ping", Name = "PostPingSystem")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<string> PingSystem()
|
||||
=> _appHost.Name;
|
||||
|
||||
/// <summary>
|
||||
/// Restarts the application.
|
||||
/// </summary>
|
||||
/// <response code="204">Server restarted.</response>
|
||||
/// <response code="403">User does not have permission to restart server.</response>
|
||||
/// <returns>No content. Server restarted.</returns>
|
||||
[HttpPost("Restart")]
|
||||
[Authorize(Policy = Policies.LocalAccessOrRequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public ActionResult RestartApplication()
|
||||
{
|
||||
_systemManager.Restart();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shuts down the application.
|
||||
/// </summary>
|
||||
/// <response code="204">Server shut down.</response>
|
||||
/// <response code="403">User does not have permission to shutdown server.</response>
|
||||
/// <returns>No content. Server shut down.</returns>
|
||||
[HttpPost("Shutdown")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public ActionResult ShutdownApplication()
|
||||
{
|
||||
_systemManager.Shutdown();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of available server log files.
|
||||
/// </summary>
|
||||
/// <response code="200">Information retrieved.</response>
|
||||
/// <response code="403">User does not have permission to get server logs.</response>
|
||||
/// <returns>An array of <see cref="LogFile"/> with the available log files.</returns>
|
||||
[HttpGet("Logs")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public ActionResult<LogFile[]> GetServerLogs()
|
||||
{
|
||||
IEnumerable<FileSystemMetadata> files;
|
||||
|
||||
try
|
||||
{
|
||||
files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting logs");
|
||||
files = Enumerable.Empty<FileSystemMetadata>();
|
||||
}
|
||||
|
||||
var result = files.Select(i => new LogFile
|
||||
{
|
||||
DateCreated = _fileSystem.GetCreationTimeUtc(i),
|
||||
DateModified = _fileSystem.GetLastWriteTimeUtc(i),
|
||||
Name = i.Name,
|
||||
Size = i.Length
|
||||
})
|
||||
.OrderByDescending(i => i.DateModified)
|
||||
.ThenByDescending(i => i.DateCreated)
|
||||
.ThenBy(i => i.Name)
|
||||
.ToArray();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about the request endpoint.
|
||||
/// </summary>
|
||||
/// <response code="200">Information retrieved.</response>
|
||||
/// <response code="403">User does not have permission to get endpoint information.</response>
|
||||
/// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns>
|
||||
[HttpGet("Endpoint")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public ActionResult<EndPointInfo> GetEndpointInfo()
|
||||
{
|
||||
return new EndPointInfo
|
||||
{
|
||||
IsLocal = HttpContext.IsLocal(),
|
||||
IsInNetwork = _networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIP())
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a log file.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the log file to get.</param>
|
||||
/// <response code="200">Log file retrieved.</response>
|
||||
/// <response code="403">User does not have permission to get log files.</response>
|
||||
/// <response code="404">Could not find a log file with the name.</response>
|
||||
/// <returns>The log file.</returns>
|
||||
[HttpGet("Logs/Log")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesFile(MediaTypeNames.Text.Plain)]
|
||||
public ActionResult GetLogFile([FromQuery, Required] string name)
|
||||
{
|
||||
var file = _fileSystem
|
||||
.GetFiles(_appPaths.LogDirectoryPath)
|
||||
.FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (file is null)
|
||||
{
|
||||
return NotFound("Log file not found.");
|
||||
}
|
||||
|
||||
// For older files, assume fully static
|
||||
var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite;
|
||||
FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
|
||||
return File(stream, "text/plain; charset=utf-8");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using MediaBrowser.Model.SyncPlay;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The time sync controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
public class TimeSyncController : BaseJellyfinApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current UTC time.
|
||||
/// </summary>
|
||||
/// <response code="200">Time returned.</response>
|
||||
/// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns>
|
||||
[HttpGet("GetUtcTime")]
|
||||
[ProducesResponseType(statusCode: StatusCodes.Status200OK)]
|
||||
public ActionResult<UtcTimeResponse> GetUtcTime()
|
||||
{
|
||||
// Important to keep the following line at the beginning
|
||||
var requestReceptionTime = DateTime.UtcNow;
|
||||
|
||||
// Important to keep the following line at the end
|
||||
var responseTransmissionTime = DateTime.UtcNow;
|
||||
|
||||
// Implementing NTP on such a high level results in this useless
|
||||
// information being sent. On the other hand it enables future additions.
|
||||
return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using System;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The trailers controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class TrailersController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ItemsController _itemsController;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TrailersController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="itemsController">Instance of <see cref="ItemsController"/>.</param>
|
||||
public TrailersController(ItemsController itemsController)
|
||||
{
|
||||
_itemsController = itemsController;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds movies and trailers similar to a given trailer.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param>
|
||||
/// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
|
||||
/// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
|
||||
/// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
|
||||
/// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
|
||||
/// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
|
||||
/// <param name="hasTrailer">Optional filter by items with trailers.</param>
|
||||
/// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
|
||||
/// <param name="parentIndexNumber">Optional filter by parent index number.</param>
|
||||
/// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
|
||||
/// <param name="isHd">Optional filter by items that are HD or not.</param>
|
||||
/// <param name="is4K">Optional filter by items that are 4K or not.</param>
|
||||
/// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
|
||||
/// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
|
||||
/// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
|
||||
/// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
|
||||
/// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
|
||||
/// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
|
||||
/// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
|
||||
/// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
|
||||
/// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
|
||||
/// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
|
||||
/// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
|
||||
/// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param>
|
||||
/// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param>
|
||||
/// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param>
|
||||
/// <param name="isMovie">Optional filter for live tv movies.</param>
|
||||
/// <param name="isSeries">Optional filter for live tv series.</param>
|
||||
/// <param name="isNews">Optional filter for live tv news.</param>
|
||||
/// <param name="isKids">Optional filter for live tv kids.</param>
|
||||
/// <param name="isSports">Optional filter for live tv sports.</param>
|
||||
/// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
|
||||
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
|
||||
/// <param name="searchTerm">Optional. Filter based on a search term.</param>
|
||||
/// <param name="sortOrder">Sort Order - Ascending, Descending.</param>
|
||||
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
|
||||
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
|
||||
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
|
||||
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
|
||||
/// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
|
||||
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
|
||||
/// <param name="isPlayed">Optional filter by items that are played, or not.</param>
|
||||
/// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
|
||||
/// <param name="enableUserData">Optional, include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
|
||||
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
|
||||
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
|
||||
/// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
|
||||
/// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
|
||||
/// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
|
||||
/// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
|
||||
/// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
|
||||
/// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
|
||||
/// <param name="isLocked">Optional filter by items that are locked.</param>
|
||||
/// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
|
||||
/// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
|
||||
/// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
|
||||
/// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
|
||||
/// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
|
||||
/// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
|
||||
/// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
|
||||
/// <param name="is3D">Optional filter by items that are 3D, or not.</param>
|
||||
/// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
|
||||
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
|
||||
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
|
||||
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
|
||||
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
|
||||
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
|
||||
/// <param name="enableImages">Optional, include image information in output.</param>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetTrailers(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? maxOfficialRating,
|
||||
[FromQuery] bool? hasThemeSong,
|
||||
[FromQuery] bool? hasThemeVideo,
|
||||
[FromQuery] bool? hasSubtitles,
|
||||
[FromQuery] bool? hasSpecialFeature,
|
||||
[FromQuery] bool? hasTrailer,
|
||||
[FromQuery] Guid? adjacentTo,
|
||||
[FromQuery] int? parentIndexNumber,
|
||||
[FromQuery] bool? hasParentalRating,
|
||||
[FromQuery] bool? isHd,
|
||||
[FromQuery] bool? is4K,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] locationTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] LocationType[] excludeLocationTypes,
|
||||
[FromQuery] bool? isMissing,
|
||||
[FromQuery] bool? isUnaired,
|
||||
[FromQuery] double? minCommunityRating,
|
||||
[FromQuery] double? minCriticRating,
|
||||
[FromQuery] DateTime? minPremiereDate,
|
||||
[FromQuery] DateTime? minDateLastSaved,
|
||||
[FromQuery] DateTime? minDateLastSavedForUser,
|
||||
[FromQuery] DateTime? maxPremiereDate,
|
||||
[FromQuery] bool? hasOverview,
|
||||
[FromQuery] bool? hasImdbId,
|
||||
[FromQuery] bool? hasTmdbId,
|
||||
[FromQuery] bool? hasTvdbId,
|
||||
[FromQuery] bool? isMovie,
|
||||
[FromQuery] bool? isSeries,
|
||||
[FromQuery] bool? isNews,
|
||||
[FromQuery] bool? isKids,
|
||||
[FromQuery] bool? isSports,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeItemIds,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] bool? recursive,
|
||||
[FromQuery] string? searchTerm,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFilter[] filters,
|
||||
[FromQuery] bool? isFavorite,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] imageTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
|
||||
[FromQuery] bool? isPlayed,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] genres,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] officialRatings,
|
||||
[FromQuery, ModelBinder(typeof(PipeDelimitedCollectionModelBinder))] string[] tags,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] int[] years,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] string? person,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] personIds,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] personTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] studios,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] artists,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] excludeArtistIds,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] artistIds,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumArtistIds,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] contributingArtistIds,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] albums,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] albumIds,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] VideoType[] videoTypes,
|
||||
[FromQuery] string? minOfficialRating,
|
||||
[FromQuery] bool? isLocked,
|
||||
[FromQuery] bool? isPlaceHolder,
|
||||
[FromQuery] bool? hasOfficialRating,
|
||||
[FromQuery] bool? collapseBoxSetItems,
|
||||
[FromQuery] int? minWidth,
|
||||
[FromQuery] int? minHeight,
|
||||
[FromQuery] int? maxWidth,
|
||||
[FromQuery] int? maxHeight,
|
||||
[FromQuery] bool? is3D,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SeriesStatus[] seriesStatus,
|
||||
[FromQuery] string? nameStartsWithOrGreater,
|
||||
[FromQuery] string? nameStartsWith,
|
||||
[FromQuery] string? nameLessThan,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] studioIds,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] genreIds,
|
||||
[FromQuery] bool enableTotalRecordCount = true,
|
||||
[FromQuery] bool? enableImages = true)
|
||||
{
|
||||
var includeItemTypes = new[] { BaseItemKind.Trailer };
|
||||
|
||||
return _itemsController
|
||||
.GetItems(
|
||||
userId,
|
||||
maxOfficialRating,
|
||||
hasThemeSong,
|
||||
hasThemeVideo,
|
||||
hasSubtitles,
|
||||
hasSpecialFeature,
|
||||
hasTrailer,
|
||||
adjacentTo,
|
||||
null,
|
||||
parentIndexNumber,
|
||||
hasParentalRating,
|
||||
isHd,
|
||||
is4K,
|
||||
locationTypes,
|
||||
excludeLocationTypes,
|
||||
isMissing,
|
||||
isUnaired,
|
||||
minCommunityRating,
|
||||
minCriticRating,
|
||||
minPremiereDate,
|
||||
minDateLastSaved,
|
||||
minDateLastSavedForUser,
|
||||
maxPremiereDate,
|
||||
hasOverview,
|
||||
hasImdbId,
|
||||
hasTmdbId,
|
||||
hasTvdbId,
|
||||
isMovie,
|
||||
isSeries,
|
||||
isNews,
|
||||
isKids,
|
||||
isSports,
|
||||
excludeItemIds,
|
||||
startIndex,
|
||||
limit,
|
||||
recursive,
|
||||
searchTerm,
|
||||
sortOrder,
|
||||
parentId,
|
||||
fields,
|
||||
excludeItemTypes,
|
||||
includeItemTypes,
|
||||
filters,
|
||||
isFavorite,
|
||||
mediaTypes,
|
||||
imageTypes,
|
||||
sortBy,
|
||||
isPlayed,
|
||||
genres,
|
||||
officialRatings,
|
||||
tags,
|
||||
years,
|
||||
enableUserData,
|
||||
imageTypeLimit,
|
||||
enableImageTypes,
|
||||
person,
|
||||
personIds,
|
||||
personTypes,
|
||||
studios,
|
||||
artists,
|
||||
excludeArtistIds,
|
||||
artistIds,
|
||||
albumArtistIds,
|
||||
contributingArtistIds,
|
||||
albums,
|
||||
albumIds,
|
||||
ids,
|
||||
videoTypes,
|
||||
minOfficialRating,
|
||||
isLocked,
|
||||
isPlaceHolder,
|
||||
hasOfficialRating,
|
||||
collapseBoxSetItems,
|
||||
minWidth,
|
||||
minHeight,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
is3D,
|
||||
seriesStatus,
|
||||
nameStartsWithOrGreater,
|
||||
nameStartsWith,
|
||||
nameLessThan,
|
||||
studioIds,
|
||||
genreIds,
|
||||
enableTotalRecordCount,
|
||||
enableImages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
using MediaBrowser.Model;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Trickplay controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize]
|
||||
public class TrickplayController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ITrickplayManager _trickplayManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TrickplayController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/>.</param>
|
||||
/// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param>
|
||||
public TrickplayController(
|
||||
ILibraryManager libraryManager,
|
||||
ITrickplayManager trickplayManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_trickplayManager = trickplayManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an image tiles playlist for trickplay.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="width">The width of a single tile.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if using an alternate version.</param>
|
||||
/// <response code="200">Tiles playlist returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the trickplay playlist file.</returns>
|
||||
[HttpGet("Videos/{itemId}/Trickplay/{width}/tiles.m3u8")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesPlaylistFile]
|
||||
public async Task<ActionResult> GetTrickplayHlsPlaylist(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] int width,
|
||||
[FromQuery] Guid? mediaSourceId)
|
||||
{
|
||||
string? playlist = await _trickplayManager.GetHlsPlaylist(mediaSourceId ?? itemId, width, User.GetToken()).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrEmpty(playlist))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Content(playlist, MimeTypes.GetMimeType("playlist.m3u8"), Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a trickplay tile image.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="width">The width of a single tile.</param>
|
||||
/// <param name="index">The index of the desired tile.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if using an alternate version.</param>
|
||||
/// <response code="200">Tile image returned.</response>
|
||||
/// <response code="200">Tile image not found at specified index.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the trickplay tiles image.</returns>
|
||||
[HttpGet("Videos/{itemId}/Trickplay/{width}/{index}.jpg")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesImageFile]
|
||||
public async Task<ActionResult> GetTrickplayTileImage(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] int width,
|
||||
[FromRoute, Required] int index,
|
||||
[FromQuery] Guid? mediaSourceId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var saveWithMedia = _libraryManager.GetLibraryOptions(item).SaveTrickplayWithMedia;
|
||||
var path = await _trickplayManager.GetTrickplayTilePathAsync(item, width, index, saveWithMedia).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
|
||||
{
|
||||
Response.Headers.ContentDisposition = "attachment";
|
||||
return PhysicalFile(path, MediaTypeNames.Image.Jpeg);
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.TV;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The tv shows controller.
|
||||
/// </summary>
|
||||
[Route("Shows")]
|
||||
[Authorize]
|
||||
public class TvShowsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly ITVSeriesManager _tvSeriesManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TvShowsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="tvSeriesManager">Instance of the <see cref="ITVSeriesManager"/> interface.</param>
|
||||
public TvShowsController(
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService,
|
||||
ITVSeriesManager tvSeriesManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
_dtoService = dtoService;
|
||||
_tvSeriesManager = tvSeriesManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of next up episodes.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id of the user to get the next up episodes for.</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="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="seriesId">Optional. Filter by series 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="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param>
|
||||
/// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param>
|
||||
/// <param name="enableResumable">Whether to include resumable episodes in next up results.</param>
|
||||
/// <param name="enableRewatching">Whether to include watched episodes in next up results.</param>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
|
||||
[HttpGet("NextUp")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetNextUp(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] Guid? seriesId,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] DateTime? nextUpDateCutoff,
|
||||
[FromQuery] bool enableTotalRecordCount = true,
|
||||
[FromQuery] bool enableResumable = true,
|
||||
[FromQuery] bool enableRewatching = false)
|
||||
{
|
||||
var user = _userManager.GetUserById(RequestHelpers.GetUserId(User, userId));
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var options = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var result = _tvSeriesManager.GetNextUp(
|
||||
new NextUpQuery
|
||||
{
|
||||
Limit = limit,
|
||||
ParentId = parentId,
|
||||
SeriesId = seriesId,
|
||||
StartIndex = startIndex,
|
||||
User = user,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue,
|
||||
EnableResumable = enableResumable,
|
||||
EnableRewatching = enableRewatching
|
||||
},
|
||||
options);
|
||||
|
||||
var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user);
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
startIndex,
|
||||
result.TotalRecordCount,
|
||||
returnItems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of upcoming episodes.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id of the user to get the upcoming episodes for.</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="fields">Optional. Specify additional fields of information to return in the output.</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="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the upcoming episodes.</returns>
|
||||
[HttpGet("Upcoming")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] bool? enableUserData)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
var minPremiereDate = DateTime.UtcNow.Date.AddDays(-1);
|
||||
|
||||
var parentIdGuid = parentId ?? Guid.Empty;
|
||||
|
||||
var options = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
IncludeItemTypes = new[] { BaseItemKind.Episode },
|
||||
OrderBy = new[] { (ItemSortBy.PremiereDate, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) },
|
||||
MinPremiereDate = minPremiereDate,
|
||||
StartIndex = startIndex,
|
||||
Limit = limit,
|
||||
ParentId = parentIdGuid,
|
||||
Recursive = true,
|
||||
DtoOptions = options
|
||||
});
|
||||
|
||||
var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user);
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
startIndex,
|
||||
itemsResult.Count,
|
||||
returnItems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets episodes for a tv season.
|
||||
/// </summary>
|
||||
/// <param name="seriesId">The series id.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
|
||||
/// <param name="season">Optional filter by season number.</param>
|
||||
/// <param name="seasonId">Optional. Filter by season id.</param>
|
||||
/// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
|
||||
/// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
|
||||
/// <param name="startItemId">Optional. Skip through the list until a given item is found.</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="enableImages">Optional, include image information in output.</param>
|
||||
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
|
||||
[HttpGet("{seriesId}/Episodes")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
|
||||
[FromRoute, Required] Guid seriesId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] int? season,
|
||||
[FromQuery] Guid? seasonId,
|
||||
[FromQuery] bool? isMissing,
|
||||
[FromQuery] Guid? adjacentTo,
|
||||
[FromQuery] Guid? startItemId,
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] ItemSortBy? sortBy)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
List<BaseItem> episodes;
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var shouldIncludeMissingEpisodes = (user is not null && user.DisplayMissingEpisodes) || User.GetIsApiKey();
|
||||
|
||||
if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value);
|
||||
if (item is not Season seasonItem)
|
||||
{
|
||||
return NotFound("No season exists with Id " + seasonId);
|
||||
}
|
||||
|
||||
episodes = seasonItem.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
else if (season.HasValue) // Season number was supplied. Get episodes by season number
|
||||
{
|
||||
var series = _libraryManager.GetItemById<Series>(seriesId);
|
||||
if (series is null)
|
||||
{
|
||||
return NotFound("Series not found");
|
||||
}
|
||||
|
||||
var seasonItem = series
|
||||
.GetSeasons(user, dtoOptions)
|
||||
.FirstOrDefault(i => i.IndexNumber == season.Value);
|
||||
|
||||
episodes = seasonItem is null ?
|
||||
new List<BaseItem>()
|
||||
: ((Season)seasonItem).GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
else // No season number or season id was supplied. Returning all episodes.
|
||||
{
|
||||
if (_libraryManager.GetItemById<BaseItem>(seriesId) is not Series series)
|
||||
{
|
||||
return NotFound("Series not found");
|
||||
}
|
||||
|
||||
episodes = series.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes).ToList();
|
||||
}
|
||||
|
||||
// Filter after the fact in case the ui doesn't want them
|
||||
if (isMissing.HasValue)
|
||||
{
|
||||
var val = isMissing.Value;
|
||||
episodes = episodes
|
||||
.Where(i => ((Episode)i).IsMissingEpisode == val)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (startItemId.HasValue)
|
||||
{
|
||||
episodes = episodes
|
||||
.SkipWhile(i => !startItemId.Value.Equals(i.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// This must be the last filter
|
||||
if (!adjacentTo.IsNullOrEmpty())
|
||||
{
|
||||
episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo.Value).ToList();
|
||||
}
|
||||
|
||||
if (sortBy == ItemSortBy.Random)
|
||||
{
|
||||
episodes.Shuffle();
|
||||
}
|
||||
|
||||
var returnItems = episodes;
|
||||
|
||||
if (startIndex.HasValue || limit.HasValue)
|
||||
{
|
||||
returnItems = ApplyPaging(episodes, startIndex, limit).ToList();
|
||||
}
|
||||
|
||||
var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user);
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
startIndex,
|
||||
episodes.Count,
|
||||
dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets seasons for a tv series.
|
||||
/// </summary>
|
||||
/// <param name="seriesId">The series id.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
|
||||
/// <param name="isSpecialSeason">Optional. Filter by special season.</param>
|
||||
/// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
|
||||
/// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
|
||||
[HttpGet("{seriesId}/Seasons")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
|
||||
[FromRoute, Required] Guid seriesId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery] bool? isSpecialSeason,
|
||||
[FromQuery] bool? isMissing,
|
||||
[FromQuery] Guid? adjacentTo,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] bool? enableUserData)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var item = _libraryManager.GetItemById<Series>(seriesId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var seasons = item.GetItemList(new InternalItemsQuery(user)
|
||||
{
|
||||
IsMissing = isMissing,
|
||||
IsSpecialSeason = isSpecialSeason,
|
||||
AdjacentTo = adjacentTo
|
||||
});
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user);
|
||||
|
||||
return new QueryResult<BaseItemDto>(returnItems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the paging.
|
||||
/// </summary>
|
||||
/// <param name="items">The items.</param>
|
||||
/// <param name="startIndex">The start index.</param>
|
||||
/// <param name="limit">The limit.</param>
|
||||
/// <returns>IEnumerable{BaseItem}.</returns>
|
||||
private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit)
|
||||
{
|
||||
// Start at
|
||||
if (startIndex.HasValue)
|
||||
{
|
||||
items = items.Skip(startIndex.Value);
|
||||
}
|
||||
|
||||
// Return limit
|
||||
if (limit.HasValue)
|
||||
{
|
||||
items = items.Take(limit.Value);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Api.Models.StreamingDtos;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The universal audio controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
public class UniversalAudioController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<UniversalAudioController> _logger;
|
||||
private readonly MediaInfoHelper _mediaInfoHelper;
|
||||
private readonly AudioHelper _audioHelper;
|
||||
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UniversalAudioController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{UniversalAudioController}"/> interface.</param>
|
||||
/// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param>
|
||||
/// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param>
|
||||
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
public UniversalAudioController(
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<UniversalAudioController> logger,
|
||||
MediaInfoHelper mediaInfoHelper,
|
||||
AudioHelper audioHelper,
|
||||
DynamicHlsHelper dynamicHlsHelper,
|
||||
IUserManager userManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_mediaInfoHelper = mediaInfoHelper;
|
||||
_audioHelper = audioHelper;
|
||||
_dynamicHlsHelper = dynamicHlsHelper;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an audio stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="container">Optional. The audio container.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="userId">Optional. The user id.</param>
|
||||
/// <param name="audioCodec">Optional. The audio codec to transcode to.</param>
|
||||
/// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param>
|
||||
/// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param>
|
||||
/// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="transcodingContainer">Optional. The container to transcode to.</param>
|
||||
/// <param name="transcodingProtocol">Optional. The transcoding protocol.</param>
|
||||
/// <param name="maxAudioSampleRate">Optional. The maximum audio sample rate.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param>
|
||||
/// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
|
||||
/// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param>
|
||||
/// <response code="200">Audio stream returned.</response>
|
||||
/// <response code="302">Redirected to remote audio stream.</response>
|
||||
/// <response code="404">Item not found.</response>
|
||||
/// <returns>A <see cref="Task"/> containing the audio file.</returns>
|
||||
[HttpGet("Audio/{itemId}/universal")]
|
||||
[HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesAudioFile]
|
||||
public async Task<ActionResult> GetUniversalAudioStream(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] string[] container,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] int? transcodingAudioChannels,
|
||||
[FromQuery] int? maxStreamingBitrate,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? transcodingContainer,
|
||||
[FromQuery] MediaStreamProtocol? transcodingProtocol,
|
||||
[FromQuery] int? maxAudioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] bool? enableRemoteMedia,
|
||||
[FromQuery] bool enableAudioVbrEncoding = true,
|
||||
[FromQuery] bool enableRedirection = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels);
|
||||
|
||||
_logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile);
|
||||
|
||||
var info = await _mediaInfoHelper.GetPlaybackInfo(
|
||||
item,
|
||||
user,
|
||||
mediaSourceId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// set device specific data
|
||||
foreach (var sourceInfo in info.MediaSources)
|
||||
{
|
||||
sourceInfo.TranscodingContainer = transcodingContainer;
|
||||
sourceInfo.TranscodingSubProtocol = transcodingProtocol ?? sourceInfo.TranscodingSubProtocol;
|
||||
_mediaInfoHelper.SetDeviceSpecificData(
|
||||
item,
|
||||
sourceInfo,
|
||||
deviceProfile,
|
||||
User,
|
||||
maxStreamingBitrate ?? deviceProfile.MaxStreamingBitrate,
|
||||
startTimeTicks ?? 0,
|
||||
mediaSourceId ?? string.Empty,
|
||||
null,
|
||||
null,
|
||||
maxAudioChannels,
|
||||
info.PlaySessionId!,
|
||||
userId ?? Guid.Empty,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Request.HttpContext.GetNormalizedRemoteIP());
|
||||
}
|
||||
|
||||
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
|
||||
|
||||
foreach (var source in info.MediaSources)
|
||||
{
|
||||
_mediaInfoHelper.NormalizeMediaSourceContainer(source, deviceProfile, DlnaProfileType.Video);
|
||||
}
|
||||
|
||||
var mediaSource = info.MediaSources[0];
|
||||
if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http && enableRedirection && mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value)
|
||||
{
|
||||
return Redirect(mediaSource.Path);
|
||||
}
|
||||
|
||||
// This one is currently very misleading as the SupportsDirectStream actually means "can direct play"
|
||||
// The definition of DirectStream also seems changed during development
|
||||
var isStatic = mediaSource.SupportsDirectStream;
|
||||
if (!isStatic && mediaSource.TranscodingSubProtocol == MediaStreamProtocol.hls)
|
||||
{
|
||||
// hls segment container can only be mpegts or fmp4 per ffmpeg documentation
|
||||
// ffmpeg option -> file extension
|
||||
// mpegts -> ts
|
||||
// fmp4 -> mp4
|
||||
var supportedHlsContainers = new[] { "ts", "mp4" };
|
||||
|
||||
// fallback to mpegts if device reports some weird value unsupported by hls
|
||||
var requestedSegmentContainer = Array.Exists(
|
||||
supportedHlsContainers,
|
||||
element => string.Equals(element, transcodingContainer, StringComparison.OrdinalIgnoreCase)) ? transcodingContainer : "ts";
|
||||
var segmentContainer = Array.Exists(
|
||||
supportedHlsContainers,
|
||||
element => string.Equals(element, mediaSource.TranscodingContainer, StringComparison.OrdinalIgnoreCase)) ? mediaSource.TranscodingContainer : requestedSegmentContainer;
|
||||
var dynamicHlsRequestDto = new HlsAudioRequestDto
|
||||
{
|
||||
Id = itemId,
|
||||
Container = ".m3u8",
|
||||
Static = isStatic,
|
||||
PlaySessionId = info.PlaySessionId,
|
||||
SegmentContainer = segmentContainer,
|
||||
MediaSourceId = mediaSourceId,
|
||||
DeviceId = deviceId,
|
||||
AudioCodec = mediaSource.TranscodeReasons == TranscodeReason.ContainerNotSupported ? "copy" : audioCodec,
|
||||
EnableAutoStreamCopy = true,
|
||||
AllowAudioStreamCopy = true,
|
||||
AllowVideoStreamCopy = true,
|
||||
AudioSampleRate = maxAudioSampleRate,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
MaxAudioBitDepth = maxAudioBitDepth,
|
||||
AudioBitRate = audioBitRate ?? maxStreamingBitrate,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
SubtitleMethod = SubtitleDeliveryMethod.Hls,
|
||||
RequireAvc = false,
|
||||
DeInterlace = false,
|
||||
RequireNonAnamorphic = false,
|
||||
EnableMpegtsM2TsMode = false,
|
||||
TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(),
|
||||
Context = EncodingContext.Static,
|
||||
StreamOptions = new Dictionary<string, string>(),
|
||||
EnableAdaptiveBitrateStreaming = false,
|
||||
EnableAudioVbrEncoding = enableAudioVbrEncoding
|
||||
};
|
||||
|
||||
return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var audioStreamingDto = new StreamingRequestDto
|
||||
{
|
||||
Id = itemId,
|
||||
Container = isStatic ? null : ("." + mediaSource.TranscodingContainer),
|
||||
Static = isStatic,
|
||||
PlaySessionId = info.PlaySessionId,
|
||||
MediaSourceId = mediaSourceId,
|
||||
DeviceId = deviceId,
|
||||
AudioCodec = audioCodec,
|
||||
EnableAutoStreamCopy = true,
|
||||
AllowAudioStreamCopy = true,
|
||||
AllowVideoStreamCopy = true,
|
||||
AudioSampleRate = maxAudioSampleRate,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate),
|
||||
MaxAudioBitDepth = maxAudioBitDepth,
|
||||
AudioChannels = maxAudioChannels,
|
||||
CopyTimestamps = true,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
SubtitleMethod = SubtitleDeliveryMethod.Embed,
|
||||
TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(),
|
||||
Context = EncodingContext.Static
|
||||
};
|
||||
|
||||
return await _audioHelper.GetAudioStream(TranscodingJobType.Progressive, audioStreamingDto).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private DeviceProfile GetDeviceProfile(
|
||||
string[] containers,
|
||||
string? transcodingContainer,
|
||||
string? audioCodec,
|
||||
MediaStreamProtocol? transcodingProtocol,
|
||||
int? transcodingAudioChannels,
|
||||
int? maxAudioSampleRate,
|
||||
int? maxAudioBitDepth,
|
||||
int? maxAudioChannels)
|
||||
{
|
||||
var deviceProfile = new DeviceProfile();
|
||||
|
||||
int len = containers.Length;
|
||||
var directPlayProfiles = new DirectPlayProfile[len];
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var parts = containers[i].Split('|', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1));
|
||||
|
||||
directPlayProfiles[i] = new DirectPlayProfile
|
||||
{
|
||||
Type = DlnaProfileType.Audio,
|
||||
Container = parts[0],
|
||||
AudioCodec = audioCodecs
|
||||
};
|
||||
}
|
||||
|
||||
deviceProfile.DirectPlayProfiles = directPlayProfiles;
|
||||
|
||||
deviceProfile.TranscodingProfiles = new[]
|
||||
{
|
||||
new TranscodingProfile
|
||||
{
|
||||
Type = DlnaProfileType.Audio,
|
||||
Context = EncodingContext.Streaming,
|
||||
Container = transcodingContainer ?? "mp3",
|
||||
AudioCodec = audioCodec ?? "mp3",
|
||||
Protocol = transcodingProtocol ?? MediaStreamProtocol.http,
|
||||
MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture)
|
||||
}
|
||||
};
|
||||
|
||||
var codecProfiles = new List<CodecProfile>();
|
||||
var conditions = new List<ProfileCondition>();
|
||||
|
||||
if (maxAudioSampleRate.HasValue)
|
||||
{
|
||||
// codec profile
|
||||
conditions.Add(
|
||||
new ProfileCondition
|
||||
{
|
||||
Condition = ProfileConditionType.LessThanEqual,
|
||||
IsRequired = false,
|
||||
Property = ProfileConditionValue.AudioSampleRate,
|
||||
Value = maxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)
|
||||
});
|
||||
}
|
||||
|
||||
if (maxAudioBitDepth.HasValue)
|
||||
{
|
||||
// codec profile
|
||||
conditions.Add(
|
||||
new ProfileCondition
|
||||
{
|
||||
Condition = ProfileConditionType.LessThanEqual,
|
||||
IsRequired = false,
|
||||
Property = ProfileConditionValue.AudioBitDepth,
|
||||
Value = maxAudioBitDepth.Value.ToString(CultureInfo.InvariantCulture)
|
||||
});
|
||||
}
|
||||
|
||||
if (maxAudioChannels.HasValue)
|
||||
{
|
||||
// codec profile
|
||||
conditions.Add(
|
||||
new ProfileCondition
|
||||
{
|
||||
Condition = ProfileConditionType.LessThanEqual,
|
||||
IsRequired = false,
|
||||
Property = ProfileConditionValue.AudioChannels,
|
||||
Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture)
|
||||
});
|
||||
}
|
||||
|
||||
if (conditions.Count > 0)
|
||||
{
|
||||
// codec profile
|
||||
codecProfiles.Add(
|
||||
new CodecProfile
|
||||
{
|
||||
Type = CodecType.Audio,
|
||||
Container = string.Join(',', containers),
|
||||
Conditions = conditions.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
deviceProfile.CodecProfiles = codecProfiles.ToArray();
|
||||
|
||||
return deviceProfile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.Models.UserDtos;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Playlists;
|
||||
using MediaBrowser.Controller.QuickConnect;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Users;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// User controller.
|
||||
/// </summary>
|
||||
[Route("Users")]
|
||||
public class UserController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly INetworkManager _networkManager;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly IAuthorizationContext _authContext;
|
||||
private readonly IServerConfigurationManager _config;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IQuickConnect _quickConnectManager;
|
||||
private readonly IPlaylistManager _playlistManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
||||
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
|
||||
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
|
||||
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
|
||||
/// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||
/// <param name="quickConnectManager">Instance of the <see cref="IQuickConnect"/> interface.</param>
|
||||
/// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param>
|
||||
public UserController(
|
||||
IUserManager userManager,
|
||||
ISessionManager sessionManager,
|
||||
INetworkManager networkManager,
|
||||
IDeviceManager deviceManager,
|
||||
IAuthorizationContext authContext,
|
||||
IServerConfigurationManager config,
|
||||
ILogger<UserController> logger,
|
||||
IQuickConnect quickConnectManager,
|
||||
IPlaylistManager playlistManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_sessionManager = sessionManager;
|
||||
_networkManager = networkManager;
|
||||
_deviceManager = deviceManager;
|
||||
_authContext = authContext;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_quickConnectManager = quickConnectManager;
|
||||
_playlistManager = playlistManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of users.
|
||||
/// </summary>
|
||||
/// <param name="isHidden">Optional filter by IsHidden=true or false.</param>
|
||||
/// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param>
|
||||
/// <response code="200">Users returned.</response>
|
||||
/// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns>
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<UserDto>> GetUsers(
|
||||
[FromQuery] bool? isHidden,
|
||||
[FromQuery] bool? isDisabled)
|
||||
{
|
||||
var users = Get(isHidden, isDisabled, false, false);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of publicly visible users for display on a login screen.
|
||||
/// </summary>
|
||||
/// <response code="200">Public users returned.</response>
|
||||
/// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns>
|
||||
[HttpGet("Public")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<UserDto>> GetPublicUsers()
|
||||
{
|
||||
// If the startup wizard hasn't been completed then just return all users
|
||||
if (!_config.Configuration.IsStartupWizardCompleted)
|
||||
{
|
||||
return Ok(Get(false, false, false, false));
|
||||
}
|
||||
|
||||
return Ok(Get(false, false, true, true));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a user by Id.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <response code="200">User returned.</response>
|
||||
/// <response code="404">User not found.</response>
|
||||
/// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns>
|
||||
[HttpGet("{userId}")]
|
||||
[Authorize(Policy = Policies.IgnoreParentalControl)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<UserDto> GetUserById([FromRoute, Required] Guid userId)
|
||||
{
|
||||
var user = _userManager.GetUserById(userId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound("User not found");
|
||||
}
|
||||
|
||||
var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIP().ToString());
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <response code="204">User deleted.</response>
|
||||
/// <response code="404">User not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns>
|
||||
[HttpDelete("{userId}")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId)
|
||||
{
|
||||
var user = _userManager.GetUserById(userId);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false);
|
||||
await _playlistManager.RemovePlaylistsAsync(userId).ConfigureAwait(false);
|
||||
await _userManager.DeleteUserAsync(userId).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="pw">The password as plain text.</param>
|
||||
/// <response code="200">User authenticated.</response>
|
||||
/// <response code="403">Sha1-hashed password only is not allowed.</response>
|
||||
/// <response code="404">User not found.</response>
|
||||
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns>
|
||||
[HttpPost("{userId}/Authenticate")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[Obsolete("Authenticate with username instead")]
|
||||
public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromQuery, Required] string pw)
|
||||
{
|
||||
var user = _userManager.GetUserById(userId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound("User not found");
|
||||
}
|
||||
|
||||
AuthenticateUserByName request = new AuthenticateUserByName
|
||||
{
|
||||
Username = user.Username,
|
||||
Pw = pw
|
||||
};
|
||||
return await AuthenticateUserByName(request).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user by name.
|
||||
/// </summary>
|
||||
/// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param>
|
||||
/// <response code="200">User authenticated.</response>
|
||||
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
|
||||
[HttpPost("AuthenticateByName")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request)
|
||||
{
|
||||
var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest
|
||||
{
|
||||
App = auth.Client,
|
||||
AppVersion = auth.Version,
|
||||
DeviceId = auth.DeviceId,
|
||||
DeviceName = auth.Device,
|
||||
Password = request.Pw,
|
||||
RemoteEndPoint = HttpContext.GetNormalizedRemoteIP().ToString(),
|
||||
Username = request.Username
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{
|
||||
// rethrow adding IP address to message
|
||||
throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIP()}] {e.Message}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a user with quick connect.
|
||||
/// </summary>
|
||||
/// <param name="request">The <see cref="QuickConnectDto"/> request.</param>
|
||||
/// <response code="200">User authenticated.</response>
|
||||
/// <response code="400">Missing token.</response>
|
||||
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
|
||||
[HttpPost("AuthenticateWithQuickConnect")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _quickConnectManager.GetAuthorizedRequest(request.Secret);
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{
|
||||
// rethrow adding IP address to message
|
||||
throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIP()}] {e.Message}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user's password.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="request">The <see cref="UpdateUserPassword"/> request.</param>
|
||||
/// <response code="204">Password successfully reset.</response>
|
||||
/// <response code="403">User is not allowed to update the password.</response>
|
||||
/// <response code="404">User not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
|
||||
[HttpPost("Password")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> UpdateUserPassword(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromBody, Required] UpdateUserPassword request)
|
||||
{
|
||||
var requestUserId = userId ?? User.GetUserId();
|
||||
var user = _userManager.GetUserById(requestUserId);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!RequestHelpers.AssertCanUpdateUser(User, user, true))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password.");
|
||||
}
|
||||
|
||||
if (request.ResetPassword)
|
||||
{
|
||||
await _userManager.ResetPassword(user).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!User.IsInRole(UserRoles.Administrator) || (userId.HasValue && User.GetUserId().Equals(userId.Value)))
|
||||
{
|
||||
var success = await _userManager.AuthenticateUser(
|
||||
user.Username,
|
||||
request.CurrentPw ?? string.Empty,
|
||||
HttpContext.GetNormalizedRemoteIP().ToString(),
|
||||
false).ConfigureAwait(false);
|
||||
|
||||
if (success is null)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered.");
|
||||
}
|
||||
}
|
||||
|
||||
await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false);
|
||||
|
||||
var currentToken = User.GetToken();
|
||||
|
||||
await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user's password.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="request">The <see cref="UpdateUserPassword"/> request.</param>
|
||||
/// <response code="204">Password successfully reset.</response>
|
||||
/// <response code="403">User is not allowed to update the password.</response>
|
||||
/// <response code="404">User not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
|
||||
[HttpPost("{userId}/Password")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public Task<ActionResult> UpdateUserPasswordLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromBody, Required] UpdateUserPassword request)
|
||||
=> UpdateUserPassword(userId, request);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="updateUser">The updated user model.</param>
|
||||
/// <response code="204">User updated.</response>
|
||||
/// <response code="400">User information was not supplied.</response>
|
||||
/// <response code="403">User update forbidden.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns>
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> UpdateUser(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromBody, Required] UserDto updateUser)
|
||||
{
|
||||
var requestUserId = userId ?? User.GetUserId();
|
||||
var user = _userManager.GetUserById(requestUserId);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!RequestHelpers.AssertCanUpdateUser(User, user, true))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed.");
|
||||
}
|
||||
|
||||
if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
|
||||
{
|
||||
await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="updateUser">The updated user model.</param>
|
||||
/// <response code="204">User updated.</response>
|
||||
/// <response code="400">User information was not supplied.</response>
|
||||
/// <response code="403">User update forbidden.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns>
|
||||
[HttpPost("{userId}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public Task<ActionResult> UpdateUserLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromBody, Required] UserDto updateUser)
|
||||
=> UpdateUser(userId, updateUser);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user policy.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="newPolicy">The new user policy.</param>
|
||||
/// <response code="204">User policy updated.</response>
|
||||
/// <response code="400">User policy was not supplied.</response>
|
||||
/// <response code="403">User policy update forbidden.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns>
|
||||
[HttpPost("{userId}/Policy")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> UpdateUserPolicy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromBody, Required] UserPolicy newPolicy)
|
||||
{
|
||||
var user = _userManager.GetUserById(userId);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// If removing admin access
|
||||
if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))
|
||||
{
|
||||
if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
|
||||
}
|
||||
}
|
||||
|
||||
// If disabling
|
||||
if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled.");
|
||||
}
|
||||
|
||||
// If disabling
|
||||
if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
|
||||
{
|
||||
if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
|
||||
}
|
||||
|
||||
var currentToken = User.GetToken();
|
||||
await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user configuration.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="userConfig">The new user configuration.</param>
|
||||
/// <response code="204">User configuration updated.</response>
|
||||
/// <response code="403">User configuration update forbidden.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("Configuration")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> UpdateUserConfiguration(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromBody, Required] UserConfiguration userConfig)
|
||||
{
|
||||
var requestUserId = userId ?? User.GetUserId();
|
||||
var user = _userManager.GetUserById(requestUserId);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!RequestHelpers.AssertCanUpdateUser(User, user, true))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed");
|
||||
}
|
||||
|
||||
await _userManager.UpdateConfigurationAsync(requestUserId, userConfig).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user configuration.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <param name="userConfig">The new user configuration.</param>
|
||||
/// <response code="204">User configuration updated.</response>
|
||||
/// <response code="403">User configuration update forbidden.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
|
||||
[HttpPost("{userId}/Configuration")]
|
||||
[Authorize]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public Task<ActionResult> UpdateUserConfigurationLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromBody, Required] UserConfiguration userConfig)
|
||||
=> UpdateUserConfiguration(userId, userConfig);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a user.
|
||||
/// </summary>
|
||||
/// <param name="request">The create user by name request body.</param>
|
||||
/// <response code="200">User created.</response>
|
||||
/// <returns>An <see cref="UserDto"/> of the new user.</returns>
|
||||
[HttpPost("New")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<UserDto>> CreateUserByName([FromBody, Required] CreateUserByName request)
|
||||
{
|
||||
var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false);
|
||||
|
||||
// no need to authenticate password for new user
|
||||
if (request.Password is not null)
|
||||
{
|
||||
await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initiates the forgot password process for a local user.
|
||||
/// </summary>
|
||||
/// <param name="forgotPasswordRequest">The forgot password request containing the entered username.</param>
|
||||
/// <response code="200">Password reset process started.</response>
|
||||
/// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns>
|
||||
[HttpPost("ForgotPassword")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest)
|
||||
{
|
||||
var ip = HttpContext.GetNormalizedRemoteIP();
|
||||
var isLocal = HttpContext.IsLocal()
|
||||
|| _networkManager.IsInLocalNetwork(ip);
|
||||
|
||||
if (!isLocal)
|
||||
{
|
||||
_logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip);
|
||||
}
|
||||
|
||||
var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redeems a forgot password pin.
|
||||
/// </summary>
|
||||
/// <param name="forgotPasswordPinRequest">The forgot password pin request containing the entered pin.</param>
|
||||
/// <response code="200">Pin reset process started.</response>
|
||||
/// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns>
|
||||
[HttpPost("ForgotPassword/Pin")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest)
|
||||
{
|
||||
var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user based on auth token.
|
||||
/// </summary>
|
||||
/// <response code="200">User returned.</response>
|
||||
/// <response code="400">Token is not owned by a user.</response>
|
||||
/// <returns>A <see cref="UserDto"/> for the authenticated user.</returns>
|
||||
[HttpGet("Me")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public ActionResult<UserDto> GetCurrentUser()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId.IsEmpty())
|
||||
{
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var user = _userManager.GetUserById(userId);
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
return _userManager.GetUserDto(user);
|
||||
}
|
||||
|
||||
private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
|
||||
{
|
||||
var users = _userManager.Users;
|
||||
|
||||
if (isDisabled.HasValue)
|
||||
{
|
||||
users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value);
|
||||
}
|
||||
|
||||
if (isHidden.HasValue)
|
||||
{
|
||||
users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value);
|
||||
}
|
||||
|
||||
if (filterByDevice)
|
||||
{
|
||||
var deviceId = User.GetDeviceId();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId));
|
||||
}
|
||||
}
|
||||
|
||||
if (filterByNetwork)
|
||||
{
|
||||
if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIP()))
|
||||
{
|
||||
users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess));
|
||||
}
|
||||
}
|
||||
|
||||
var result = users
|
||||
.OrderBy(u => u.Username)
|
||||
.Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIP().ToString()));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// User library controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize]
|
||||
public class UserLibraryController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserDataManager _userDataRepository;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IUserViewManager _userViewManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserLibraryController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
public UserLibraryController(
|
||||
IUserManager userManager,
|
||||
IUserDataManager userDataRepository,
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService,
|
||||
IUserViewManager userViewManager,
|
||||
IFileSystem fileSystem)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userDataRepository = userDataRepository;
|
||||
_libraryManager = libraryManager;
|
||||
_dtoService = dtoService;
|
||||
_userViewManager = userViewManager;
|
||||
_fileSystem = fileSystem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an item from a user's library.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Item returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the item.</returns>
|
||||
[HttpGet("Items/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BaseItemDto>> GetItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = itemId.IsEmpty()
|
||||
? _libraryManager.GetUserRootFolder()
|
||||
: _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an item from a user's library.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Item returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the item.</returns>
|
||||
[HttpGet("Users/{userId}/Items/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public Task<ActionResult<BaseItemDto>> GetItemLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
=> GetItem(userId, itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the root folder from a user's library.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <response code="200">Root folder returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
|
||||
[HttpGet("Items/Root")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<BaseItemDto> GetRootFolder([FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = _libraryManager.GetUserRootFolder();
|
||||
var dtoOptions = new DtoOptions();
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the root folder from a user's library.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <response code="200">Root folder returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
|
||||
[HttpGet("Users/{userId}/Items/Root")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<BaseItemDto> GetRootFolderLegacy(
|
||||
[FromRoute, Required] Guid userId)
|
||||
=> GetRootFolder(userId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets intros to play before the main media item plays.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Intros returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
|
||||
[HttpGet("Items/{itemId}/Intros")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = itemId.IsEmpty()
|
||||
? _libraryManager.GetUserRootFolder()
|
||||
: _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false);
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray();
|
||||
|
||||
return new QueryResult<BaseItemDto>(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets intros to play before the main media item plays.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Intros returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
|
||||
[HttpGet("Users/{userId}/Items/{itemId}/Intros")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public Task<ActionResult<QueryResult<BaseItemDto>>> GetIntrosLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
=> GetIntros(userId, itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Marks an item as a favorite.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Item marked as favorite.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpPost("UserFavoriteItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<UserItemDataDto> MarkFavoriteItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = itemId.IsEmpty()
|
||||
? _libraryManager.GetUserRootFolder()
|
||||
: _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return MarkFavorite(user, item, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an item as a favorite.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Item marked as favorite.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<UserItemDataDto> MarkFavoriteItemLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
=> MarkFavoriteItem(userId, itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Unmarks item as a favorite.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Item unmarked as favorite.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpDelete("UserFavoriteItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<UserItemDataDto> UnmarkFavoriteItem(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = itemId.IsEmpty()
|
||||
? _libraryManager.GetUserRootFolder()
|
||||
: _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return MarkFavorite(user, item, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unmarks item as a favorite.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Item unmarked as favorite.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<UserItemDataDto> UnmarkFavoriteItemLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
=> UnmarkFavoriteItem(userId, itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a user's saved personal rating for an item.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Personal rating removed.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpDelete("UserItems/{itemId}/Rating")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<UserItemDataDto?> DeleteUserItemRating(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = itemId.IsEmpty()
|
||||
? _libraryManager.GetUserRootFolder()
|
||||
: _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return UpdateUserItemRatingInternal(user, item, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a user's saved personal rating for an item.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Personal rating removed.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpDelete("Users/{userId}/Items/{itemId}/Rating")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<UserItemDataDto?> DeleteUserItemRatingLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
=> DeleteUserItemRating(userId, itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user's rating for an item.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param>
|
||||
/// <response code="200">Item rating updated.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpPost("UserItems/{itemId}/Rating")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<UserItemDataDto?> UpdateUserItemRating(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] bool? likes)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = itemId.IsEmpty()
|
||||
? _libraryManager.GetUserRootFolder()
|
||||
: _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return UpdateUserItemRatingInternal(user, item, likes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a user's rating for an item.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param>
|
||||
/// <response code="200">Item rating updated.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
|
||||
[HttpPost("Users/{userId}/Items/{itemId}/Rating")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<UserItemDataDto?> UpdateUserItemRatingLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] bool? likes)
|
||||
=> UpdateUserItemRating(userId, itemId, likes);
|
||||
|
||||
/// <summary>
|
||||
/// Gets local trailers for an item.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response>
|
||||
/// <returns>The items local trailers.</returns>
|
||||
[HttpGet("Items/{itemId}/LocalTrailers")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = itemId.IsEmpty()
|
||||
? _libraryManager.GetUserRootFolder()
|
||||
: _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
if (item is IHasTrailers hasTrailers)
|
||||
{
|
||||
var trailers = hasTrailers.LocalTrailers;
|
||||
return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable());
|
||||
}
|
||||
|
||||
return Ok(item.GetExtras()
|
||||
.Where(e => e.ExtraType == ExtraType.Trailer)
|
||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets local trailers for an item.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response>
|
||||
/// <returns>The items local trailers.</returns>
|
||||
[HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailersLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
=> GetLocalTrailers(userId, itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets special features for an item.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Special features returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the special features.</returns>
|
||||
[HttpGet("Items/{itemId}/SpecialFeatures")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var item = itemId.IsEmpty()
|
||||
? _libraryManager.GetUserRootFolder()
|
||||
: _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
return Ok(item
|
||||
.GetExtras()
|
||||
.Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value))
|
||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets special features for an item.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="itemId">Item id.</param>
|
||||
/// <response code="200">Special features returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the special features.</returns>
|
||||
[HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeaturesLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromRoute, Required] Guid itemId)
|
||||
=> GetSpecialFeatures(userId, itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets latest media.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="isPlayed">Filter by items that are played, or not.</param>
|
||||
/// <param name="enableImages">Optional. include image information in output.</param>
|
||||
/// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="enableUserData">Optional. include user data.</param>
|
||||
/// <param name="limit">Return item limit.</param>
|
||||
/// <param name="groupItems">Whether or not to group items into a parent container.</param>
|
||||
/// <response code="200">Latest media returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the latest media.</returns>
|
||||
[HttpGet("Items/Latest")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery] bool? isPlayed,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] bool groupItems = true)
|
||||
{
|
||||
var requestUserId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(requestUserId);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!isPlayed.HasValue)
|
||||
{
|
||||
if (user.HidePlayedInLatest)
|
||||
{
|
||||
isPlayed = false;
|
||||
}
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var list = _userViewManager.GetLatestItems(
|
||||
new LatestItemsQuery
|
||||
{
|
||||
GroupItems = groupItems,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
IsPlayed = isPlayed,
|
||||
Limit = limit,
|
||||
ParentId = parentId ?? Guid.Empty,
|
||||
User = user,
|
||||
},
|
||||
dtoOptions);
|
||||
|
||||
var dtos = list.Select(i =>
|
||||
{
|
||||
var item = i.Item2[0];
|
||||
var childCount = 0;
|
||||
|
||||
if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum || i.Item1 is Series ))
|
||||
{
|
||||
item = i.Item1;
|
||||
childCount = i.Item2.Count;
|
||||
}
|
||||
|
||||
var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
|
||||
dto.ChildCount = childCount;
|
||||
|
||||
return dto;
|
||||
});
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets latest media.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="isPlayed">Filter by items that are played, or not.</param>
|
||||
/// <param name="enableImages">Optional. include image information in output.</param>
|
||||
/// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="enableUserData">Optional. include user data.</param>
|
||||
/// <param name="limit">Return item limit.</param>
|
||||
/// <param name="groupItems">Whether or not to group items into a parent container.</param>
|
||||
/// <response code="200">Latest media returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the latest media.</returns>
|
||||
[HttpGet("Users/{userId}/Items/Latest")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<IEnumerable<BaseItemDto>> GetLatestMediaLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery] bool? isPlayed,
|
||||
[FromQuery] bool? enableImages,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] bool groupItems = true)
|
||||
=> GetLatestMedia(
|
||||
userId,
|
||||
parentId,
|
||||
fields,
|
||||
includeItemTypes,
|
||||
isPlayed,
|
||||
enableImages,
|
||||
imageTypeLimit,
|
||||
enableImageTypes,
|
||||
enableUserData,
|
||||
limit,
|
||||
groupItems);
|
||||
|
||||
private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
|
||||
{
|
||||
if (item is Person)
|
||||
{
|
||||
var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
|
||||
var performFullRefresh = !hasMetadata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
|
||||
|
||||
if (!hasMetadata)
|
||||
{
|
||||
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
|
||||
ForceSave = performFullRefresh
|
||||
};
|
||||
|
||||
await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the favorite.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="isFavorite">if set to <c>true</c> [is favorite].</param>
|
||||
private UserItemDataDto MarkFavorite(User user, BaseItem item, bool isFavorite)
|
||||
{
|
||||
// Get the user data for this item
|
||||
var data = _userDataRepository.GetUserData(user, item);
|
||||
|
||||
if (data is not null)
|
||||
{
|
||||
// Set favorite status
|
||||
data.IsFavorite = isFavorite;
|
||||
|
||||
_userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
|
||||
}
|
||||
|
||||
return _userDataRepository.GetUserDataDto(item, user)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the user item rating.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="likes">if set to <c>true</c> [likes].</param>
|
||||
private UserItemDataDto? UpdateUserItemRatingInternal(User user, BaseItem item, bool? likes)
|
||||
{
|
||||
// Get the user data for this item
|
||||
var data = _userDataRepository.GetUserData(user, item);
|
||||
|
||||
if (data is not null)
|
||||
{
|
||||
data.Likes = likes;
|
||||
|
||||
_userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
|
||||
}
|
||||
|
||||
return _userDataRepository.GetUserDataDto(item, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Api.Models.UserViewDtos;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Library;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// User views controller.
|
||||
/// </summary>
|
||||
[Route("")]
|
||||
[Authorize]
|
||||
public class UserViewsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserViewManager _userViewManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserViewsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public UserViewsController(
|
||||
IUserManager userManager,
|
||||
IUserViewManager userViewManager,
|
||||
IDtoService dtoService,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_userViewManager = userViewManager;
|
||||
_dtoService = dtoService;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get user views.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param>
|
||||
/// <param name="presetViews">Preset views.</param>
|
||||
/// <param name="includeHidden">Whether or not to include hidden content.</param>
|
||||
/// <response code="200">User views returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the user views.</returns>
|
||||
[HttpGet("UserViews")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public QueryResult<BaseItemDto> GetUserViews(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] bool? includeExternalContent,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] CollectionType?[] presetViews,
|
||||
[FromQuery] bool includeHidden = false)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value) ?? throw new ResourceNotFoundException();
|
||||
|
||||
var query = new UserViewQuery { User = user, IncludeHidden = includeHidden };
|
||||
|
||||
if (includeExternalContent.HasValue)
|
||||
{
|
||||
query.IncludeExternalContent = includeExternalContent.Value;
|
||||
}
|
||||
|
||||
if (presetViews.Length != 0)
|
||||
{
|
||||
query.PresetViews = presetViews;
|
||||
}
|
||||
|
||||
var folders = _userViewManager.GetUserViews(query);
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId];
|
||||
|
||||
var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user));
|
||||
|
||||
return new QueryResult<BaseItemDto>(dtos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get user views.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param>
|
||||
/// <param name="presetViews">Preset views.</param>
|
||||
/// <param name="includeHidden">Whether or not to include hidden content.</param>
|
||||
/// <response code="200">User views returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the user views.</returns>
|
||||
[HttpGet("Users/{userId}/Views")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public QueryResult<BaseItemDto> GetUserViewsLegacy(
|
||||
[FromRoute, Required] Guid userId,
|
||||
[FromQuery] bool? includeExternalContent,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] CollectionType?[] presetViews,
|
||||
[FromQuery] bool includeHidden = false)
|
||||
=> GetUserViews(userId, includeExternalContent, presetViews, includeHidden);
|
||||
|
||||
/// <summary>
|
||||
/// Get user view grouping options.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <response code="200">User view grouping options returned.</response>
|
||||
/// <response code="404">User not found.</response>
|
||||
/// <returns>
|
||||
/// An <see cref="OkResult"/> containing the user view grouping options
|
||||
/// or a <see cref="NotFoundResult"/> if user not found.
|
||||
/// </returns>
|
||||
[HttpGet("UserViews/GroupingOptions")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(_libraryManager.GetUserRootFolder()
|
||||
.GetChildren(user, true)
|
||||
.OfType<Folder>()
|
||||
.Where(UserView.IsEligibleForGrouping)
|
||||
.Select(i => new SpecialViewOptionDto
|
||||
{
|
||||
Name = i.Name,
|
||||
Id = i.Id.ToString("N", CultureInfo.InvariantCulture)
|
||||
})
|
||||
.OrderBy(i => i.Name)
|
||||
.AsEnumerable());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get user view grouping options.
|
||||
/// </summary>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <response code="200">User view grouping options returned.</response>
|
||||
/// <response code="404">User not found.</response>
|
||||
/// <returns>
|
||||
/// An <see cref="OkResult"/> containing the user view grouping options
|
||||
/// or a <see cref="NotFoundResult"/> if user not found.
|
||||
/// </returns>
|
||||
[HttpGet("Users/{userId}/GroupingOptions")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[Obsolete("Kept for backwards compatibility")]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptionsLegacy(
|
||||
[FromRoute, Required] Guid userId)
|
||||
=> GetGroupingOptions(userId);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Mime;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Attachments controller.
|
||||
/// </summary>
|
||||
[Route("Videos")]
|
||||
public class VideoAttachmentsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IAttachmentExtractor _attachmentExtractor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="VideoAttachmentsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param>
|
||||
public VideoAttachmentsController(
|
||||
ILibraryManager libraryManager,
|
||||
IAttachmentExtractor attachmentExtractor)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_attachmentExtractor = attachmentExtractor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get video attachment.
|
||||
/// </summary>
|
||||
/// <param name="videoId">Video ID.</param>
|
||||
/// <param name="mediaSourceId">Media Source ID.</param>
|
||||
/// <param name="index">Attachment Index.</param>
|
||||
/// <response code="200">Attachment retrieved.</response>
|
||||
/// <response code="404">Video or attachment not found.</response>
|
||||
/// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns>
|
||||
[HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")]
|
||||
[ProducesFile(MediaTypeNames.Application.Octet)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> GetAttachment(
|
||||
[FromRoute, Required] Guid videoId,
|
||||
[FromRoute, Required] string mediaSourceId,
|
||||
[FromRoute, Required] int index)
|
||||
{
|
||||
try
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(videoId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var (attachment, stream) = await _attachmentExtractor.GetAttachment(
|
||||
item,
|
||||
mediaSourceId,
|
||||
index,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var contentType = string.IsNullOrWhiteSpace(attachment.MimeType)
|
||||
? MediaTypeNames.Application.Octet
|
||||
: attachment.MimeType;
|
||||
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
catch (ResourceNotFoundException e)
|
||||
{
|
||||
return NotFound(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Attributes;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using MediaBrowser.Model.Net;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The videos controller.
|
||||
/// </summary>
|
||||
public class VideosController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly ITranscodeManager _transcodeManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly EncodingHelper _encodingHelper;
|
||||
|
||||
private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="VideosController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
|
||||
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
public VideosController(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDtoService dtoService,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
ITranscodeManager transcodeManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
EncodingHelper encodingHelper)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dtoService = dtoService;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_transcodeManager = transcodeManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_encodingHelper = encodingHelper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional parts for a video.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <response code="200">Additional parts returned.</response>
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the parts.</returns>
|
||||
[HttpGet("{itemId}/AdditionalParts")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
|
||||
var item = itemId.IsEmpty()
|
||||
? (userId.IsNullOrEmpty()
|
||||
? _libraryManager.RootFolder
|
||||
: _libraryManager.GetUserRootFolder())
|
||||
: _libraryManager.GetItemById<BaseItem>(itemId, user);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
BaseItemDto[] items;
|
||||
if (item is Video video)
|
||||
{
|
||||
items = video.GetAdditionalParts()
|
||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video))
|
||||
.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
items = Array.Empty<BaseItemDto>();
|
||||
}
|
||||
|
||||
var result = new QueryResult<BaseItemDto>(items);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes alternate video sources.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <response code="204">Alternate sources deleted.</response>
|
||||
/// <response code="404">Video not found.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="NotFoundResult"/> if the video doesn't exist.</returns>
|
||||
[HttpDelete("{itemId}/AlternateSources")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (item.LinkedAlternateVersions.Length == 0)
|
||||
{
|
||||
item = _libraryManager.GetItemById<Video>(Guid.Parse(item.PrimaryVersionId));
|
||||
}
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
foreach (var link in item.GetLinkedAlternateVersions())
|
||||
{
|
||||
link.SetPrimaryVersionId(null);
|
||||
link.LinkedAlternateVersions = Array.Empty<LinkedChild>();
|
||||
|
||||
await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
item.LinkedAlternateVersions = Array.Empty<LinkedChild>();
|
||||
item.SetPrimaryVersionId(null);
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges videos into a single record.
|
||||
/// </summary>
|
||||
/// <param name="ids">Item id list. This allows multiple, comma delimited.</param>
|
||||
/// <response code="204">Videos merged.</response>
|
||||
/// <response code="400">Supply at least 2 video ids.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns>
|
||||
[HttpPost("MergeVersions")]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
var items = ids
|
||||
.Select(i => _libraryManager.GetItemById<BaseItem>(i, userId))
|
||||
.OfType<Video>()
|
||||
.OrderBy(i => i.Id)
|
||||
.ToList();
|
||||
|
||||
if (items.Count < 2)
|
||||
{
|
||||
return BadRequest("Please supply at least two videos to merge.");
|
||||
}
|
||||
|
||||
var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId));
|
||||
if (primaryVersion is null)
|
||||
{
|
||||
primaryVersion = items
|
||||
.OrderBy(i =>
|
||||
{
|
||||
if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0)
|
||||
.First();
|
||||
}
|
||||
|
||||
var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList();
|
||||
|
||||
foreach (var item in items.Where(i => !i.Id.Equals(primaryVersion.Id)))
|
||||
{
|
||||
item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture));
|
||||
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
alternateVersionsOfPrimary.Add(new LinkedChild
|
||||
{
|
||||
Path = item.Path,
|
||||
ItemId = item.Id
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var linkedItem in item.LinkedAlternateVersions)
|
||||
{
|
||||
if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
alternateVersionsOfPrimary.Add(linkedItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.LinkedAlternateVersions.Length > 0)
|
||||
{
|
||||
item.LinkedAlternateVersions = Array.Empty<LinkedChild>();
|
||||
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray();
|
||||
await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a video stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
|
||||
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
|
||||
/// <param name="params">The streaming parameters.</param>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The segment length.</param>
|
||||
/// <param name="minSegments">The minimum number of segments.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="audioCodec">Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.</param>
|
||||
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
|
||||
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
|
||||
/// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
|
||||
/// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
|
||||
/// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
|
||||
/// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
|
||||
/// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
|
||||
/// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
|
||||
/// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
|
||||
/// <param name="maxRefFrames">Optional.</param>
|
||||
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
|
||||
/// <param name="requireAvc">Optional. Whether to require avc.</param>
|
||||
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
|
||||
/// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
|
||||
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
|
||||
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
|
||||
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.</param>
|
||||
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
|
||||
/// <param name="transcodeReasons">Optional. The transcoding reason.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
|
||||
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
|
||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||
/// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
|
||||
/// <response code="200">Video stream returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
|
||||
[HttpGet("{itemId}/stream")]
|
||||
[HttpHead("{itemId}/stream", Name = "HeadVideoStream")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesVideoFile]
|
||||
public async Task<ActionResult> GetVideoStream(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
[FromQuery] int? audioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? width,
|
||||
[FromQuery] int? height,
|
||||
[FromQuery] int? maxWidth,
|
||||
[FromQuery] int? maxHeight,
|
||||
[FromQuery] int? videoBitRate,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] SubtitleDeliveryMethod? subtitleMethod,
|
||||
[FromQuery] int? maxRefFrames,
|
||||
[FromQuery] int? maxVideoBitDepth,
|
||||
[FromQuery] bool? requireAvc,
|
||||
[FromQuery] bool? deInterlace,
|
||||
[FromQuery] bool? requireNonAnamorphic,
|
||||
[FromQuery] int? transcodingMaxAudioChannels,
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
[FromQuery] EncodingContext? context,
|
||||
[FromQuery] Dictionary<string, string> streamOptions,
|
||||
[FromQuery] bool enableAudioVbrEncoding = true)
|
||||
{
|
||||
var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
|
||||
// CTS lifecycle is managed internally.
|
||||
var cancellationTokenSource = new CancellationTokenSource();
|
||||
var streamingRequest = new VideoRequestDto
|
||||
{
|
||||
Id = itemId,
|
||||
Container = container,
|
||||
Static = @static ?? false,
|
||||
Params = @params,
|
||||
Tag = tag,
|
||||
PlaySessionId = playSessionId,
|
||||
SegmentContainer = segmentContainer,
|
||||
SegmentLength = segmentLength,
|
||||
MinSegments = minSegments,
|
||||
MediaSourceId = mediaSourceId,
|
||||
DeviceId = deviceId,
|
||||
AudioCodec = audioCodec,
|
||||
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
|
||||
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
|
||||
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
|
||||
AudioSampleRate = audioSampleRate,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
AudioBitRate = audioBitRate,
|
||||
MaxAudioBitDepth = maxAudioBitDepth,
|
||||
AudioChannels = audioChannels,
|
||||
Profile = profile,
|
||||
Level = level,
|
||||
Framerate = framerate,
|
||||
MaxFramerate = maxFramerate,
|
||||
CopyTimestamps = copyTimestamps ?? false,
|
||||
StartTimeTicks = startTimeTicks,
|
||||
Width = width,
|
||||
Height = height,
|
||||
MaxWidth = maxWidth,
|
||||
MaxHeight = maxHeight,
|
||||
VideoBitRate = videoBitRate,
|
||||
SubtitleStreamIndex = subtitleStreamIndex,
|
||||
SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode,
|
||||
MaxRefFrames = maxRefFrames,
|
||||
MaxVideoBitDepth = maxVideoBitDepth,
|
||||
RequireAvc = requireAvc ?? false,
|
||||
DeInterlace = deInterlace ?? false,
|
||||
RequireNonAnamorphic = requireNonAnamorphic ?? false,
|
||||
TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
|
||||
CpuCoreLimit = cpuCoreLimit,
|
||||
LiveStreamId = liveStreamId,
|
||||
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false,
|
||||
VideoCodec = videoCodec,
|
||||
SubtitleCodec = subtitleCodec,
|
||||
TranscodeReasons = transcodeReasons,
|
||||
AudioStreamIndex = audioStreamIndex,
|
||||
VideoStreamIndex = videoStreamIndex,
|
||||
Context = context ?? EncodingContext.Streaming,
|
||||
StreamOptions = streamOptions,
|
||||
EnableAudioVbrEncoding = enableAudioVbrEncoding
|
||||
};
|
||||
|
||||
var state = await StreamingHelpers.GetStreamingState(
|
||||
streamingRequest,
|
||||
HttpContext,
|
||||
_mediaSourceManager,
|
||||
_userManager,
|
||||
_libraryManager,
|
||||
_serverConfigurationManager,
|
||||
_mediaEncoder,
|
||||
_encodingHelper,
|
||||
_transcodeManager,
|
||||
_transcodingJobType,
|
||||
cancellationTokenSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (@static.HasValue && @static.Value && state.DirectStreamProvider is not null)
|
||||
{
|
||||
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId);
|
||||
if (liveStreamInfo is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream());
|
||||
// TODO (moved from MediaBrowser.Api): Don't hardcode contentType
|
||||
return File(liveStream, MimeTypes.GetMimeType("file.ts"));
|
||||
}
|
||||
|
||||
// Static remote stream
|
||||
if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http)
|
||||
{
|
||||
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, HttpContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File)
|
||||
{
|
||||
return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically");
|
||||
}
|
||||
|
||||
// Static stream
|
||||
if (@static.HasValue && @static.Value && !(state.MediaSource.VideoType == VideoType.BluRay || state.MediaSource.VideoType == VideoType.Dvd))
|
||||
{
|
||||
var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
|
||||
|
||||
if (state.MediaSource.IsInfiniteStream)
|
||||
{
|
||||
var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodeManager);
|
||||
return File(liveStream, contentType);
|
||||
}
|
||||
|
||||
return FileStreamResponseHelpers.GetStaticFileResult(
|
||||
state.MediaPath,
|
||||
contentType);
|
||||
}
|
||||
|
||||
// Need to start ffmpeg (because media can't be returned directly)
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, EncoderPreset.superfast);
|
||||
return await FileStreamResponseHelpers.GetTranscodedFile(
|
||||
state,
|
||||
isHeadRequest,
|
||||
HttpContext,
|
||||
_transcodeManager,
|
||||
ffmpegCommandLineArguments,
|
||||
_transcodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a video stream.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
|
||||
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
|
||||
/// <param name="params">The streaming parameters.</param>
|
||||
/// <param name="tag">The tag.</param>
|
||||
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The segment length.</param>
|
||||
/// <param name="minSegments">The minimum number of segments.</param>
|
||||
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
|
||||
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
|
||||
/// <param name="audioCodec">Optional. Specify an audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension.</param>
|
||||
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
|
||||
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
|
||||
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
|
||||
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
|
||||
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
|
||||
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
|
||||
/// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
|
||||
/// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
|
||||
/// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
|
||||
/// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
|
||||
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
|
||||
/// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
|
||||
/// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
|
||||
/// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param>
|
||||
/// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param>
|
||||
/// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
|
||||
/// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
|
||||
/// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
|
||||
/// <param name="maxRefFrames">Optional.</param>
|
||||
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
|
||||
/// <param name="requireAvc">Optional. Whether to require avc.</param>
|
||||
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
|
||||
/// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
|
||||
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
|
||||
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
|
||||
/// <param name="liveStreamId">The live stream id.</param>
|
||||
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
|
||||
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension.</param>
|
||||
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
|
||||
/// <param name="transcodeReasons">Optional. The transcoding reason.</param>
|
||||
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
|
||||
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
|
||||
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
|
||||
/// <param name="streamOptions">Optional. The streaming options.</param>
|
||||
/// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
|
||||
/// <response code="200">Video stream returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
|
||||
[HttpGet("{itemId}/stream.{container}")]
|
||||
[HttpHead("{itemId}/stream.{container}", Name = "HeadVideoStreamByContainer")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesVideoFile]
|
||||
public Task<ActionResult> GetVideoStreamByContainer(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
[FromQuery] int? audioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] int? width,
|
||||
[FromQuery] int? height,
|
||||
[FromQuery] int? maxWidth,
|
||||
[FromQuery] int? maxHeight,
|
||||
[FromQuery] int? videoBitRate,
|
||||
[FromQuery] int? subtitleStreamIndex,
|
||||
[FromQuery] SubtitleDeliveryMethod? subtitleMethod,
|
||||
[FromQuery] int? maxRefFrames,
|
||||
[FromQuery] int? maxVideoBitDepth,
|
||||
[FromQuery] bool? requireAvc,
|
||||
[FromQuery] bool? deInterlace,
|
||||
[FromQuery] bool? requireNonAnamorphic,
|
||||
[FromQuery] int? transcodingMaxAudioChannels,
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
[FromQuery] EncodingContext? context,
|
||||
[FromQuery] Dictionary<string, string> streamOptions,
|
||||
[FromQuery] bool enableAudioVbrEncoding = true)
|
||||
{
|
||||
return GetVideoStream(
|
||||
itemId,
|
||||
container,
|
||||
@static,
|
||||
@params,
|
||||
tag,
|
||||
deviceProfileId,
|
||||
playSessionId,
|
||||
segmentContainer,
|
||||
segmentLength,
|
||||
minSegments,
|
||||
mediaSourceId,
|
||||
deviceId,
|
||||
audioCodec,
|
||||
enableAutoStreamCopy,
|
||||
allowVideoStreamCopy,
|
||||
allowAudioStreamCopy,
|
||||
audioSampleRate,
|
||||
maxAudioBitDepth,
|
||||
audioBitRate,
|
||||
audioChannels,
|
||||
maxAudioChannels,
|
||||
profile,
|
||||
level,
|
||||
framerate,
|
||||
maxFramerate,
|
||||
copyTimestamps,
|
||||
startTimeTicks,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
videoBitRate,
|
||||
subtitleStreamIndex,
|
||||
subtitleMethod,
|
||||
maxRefFrames,
|
||||
maxVideoBitDepth,
|
||||
requireAvc,
|
||||
deInterlace,
|
||||
requireNonAnamorphic,
|
||||
transcodingMaxAudioChannels,
|
||||
cpuCoreLimit,
|
||||
liveStreamId,
|
||||
enableMpegtsM2TsMode,
|
||||
videoCodec,
|
||||
subtitleCodec,
|
||||
transcodeReasons,
|
||||
audioStreamIndex,
|
||||
videoStreamIndex,
|
||||
context,
|
||||
streamOptions,
|
||||
enableAudioVbrEncoding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Years controller.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class YearsController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="YearsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
|
||||
public YearsController(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDtoService dtoService)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dtoService = dtoService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get years.
|
||||
/// </summary>
|
||||
/// <param name="startIndex">Skips over a given number of items within the results. Use for paging.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
|
||||
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
|
||||
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
|
||||
/// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param>
|
||||
/// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
|
||||
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
|
||||
/// <param name="enableUserData">Optional. Include user data.</param>
|
||||
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
|
||||
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
|
||||
/// <param name="userId">User Id.</param>
|
||||
/// <param name="recursive">Search recursively.</param>
|
||||
/// <param name="enableImages">Optional. Include image information in output.</param>
|
||||
/// <response code="200">Year query returned.</response>
|
||||
/// <returns> A <see cref="QueryResult{BaseItemDto}"/> containing the year result.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<BaseItemDto>> GetYears(
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] SortOrder[] sortOrder,
|
||||
[FromQuery] Guid? parentId,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemFields[] fields,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] excludeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] BaseItemKind[] includeItemTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] MediaType[] mediaTypes,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ItemSortBy[] sortBy,
|
||||
[FromQuery] bool? enableUserData,
|
||||
[FromQuery] int? imageTypeLimit,
|
||||
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] bool recursive = true,
|
||||
[FromQuery] bool? enableImages = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
MediaTypes = mediaTypes,
|
||||
DtoOptions = dtoOptions
|
||||
};
|
||||
|
||||
bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes);
|
||||
|
||||
IReadOnlyList<BaseItem> items;
|
||||
int totalCount = -1;
|
||||
if (parentItem.IsFolder)
|
||||
{
|
||||
var folder = (Folder)parentItem;
|
||||
|
||||
if (userId.IsNullOrEmpty())
|
||||
{
|
||||
items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
items = recursive ? folder.GetRecursiveChildren(user, query, out totalCount) : folder.GetChildren(user, true).Where(Filter).ToArray();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
items = new[] { parentItem }.Where(Filter).ToList();
|
||||
}
|
||||
|
||||
var extractedItems = GetAllItems(items);
|
||||
|
||||
var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder));
|
||||
|
||||
var ibnItemsArray = filteredItems.ToList();
|
||||
|
||||
IEnumerable<BaseItem> ibnItems = ibnItemsArray;
|
||||
|
||||
if (startIndex.HasValue || limit.HasValue)
|
||||
{
|
||||
if (startIndex.HasValue)
|
||||
{
|
||||
ibnItems = ibnItems.Skip(startIndex.Value);
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
ibnItems = ibnItems.Take(limit.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>()));
|
||||
|
||||
var dtos = tuples.Select(i => _dtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user));
|
||||
|
||||
var result = new QueryResult<BaseItemDto>(
|
||||
startIndex,
|
||||
totalCount == -1 ? ibnItemsArray.Count : totalCount,
|
||||
dtos.Where(i => i is not null).ToArray());
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a year.
|
||||
/// </summary>
|
||||
/// <param name="year">The year.</param>
|
||||
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
|
||||
/// <response code="200">Year returned.</response>
|
||||
/// <response code="404">Year not found.</response>
|
||||
/// <returns>
|
||||
/// An <see cref="OkResult"/> containing the year,
|
||||
/// or a <see cref="NotFoundResult"/> if year not found.
|
||||
/// </returns>
|
||||
[HttpGet("{year}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<BaseItemDto> GetYear([FromRoute, Required] int year, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var item = _libraryManager.GetYear(year);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions);
|
||||
}
|
||||
|
||||
private bool FilterItem(BaseItem f, IReadOnlyCollection<BaseItemKind> excludeItemTypes, IReadOnlyCollection<BaseItemKind> includeItemTypes, IReadOnlyCollection<MediaType> mediaTypes)
|
||||
{
|
||||
var baseItemKind = f.GetBaseItemKind();
|
||||
// Exclude item types
|
||||
if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(baseItemKind))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Include item types
|
||||
if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(baseItemKind))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Include MediaTypes
|
||||
if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private IEnumerable<BaseItem> GetAllItems(IEnumerable<BaseItem> items)
|
||||
{
|
||||
return items
|
||||
.Select(i => i.ProductionYear ?? 0)
|
||||
.Where(i => i > 0)
|
||||
.Distinct()
|
||||
.Select(_libraryManager.GetYear);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using Jellyfin.Api.Constants;
|
||||
|
||||
namespace Jellyfin.Api.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for <see cref="ClaimsPrincipal"/>.
|
||||
/// </summary>
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Get user id from claims.
|
||||
/// </summary>
|
||||
/// <param name="user">Current claims principal.</param>
|
||||
/// <returns>User id.</returns>
|
||||
public static Guid GetUserId(this ClaimsPrincipal user)
|
||||
{
|
||||
var value = GetClaimValue(user, InternalClaimTypes.UserId);
|
||||
return string.IsNullOrEmpty(value)
|
||||
? default
|
||||
: 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(this 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(this 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(this 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(this 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(this ClaimsPrincipal user)
|
||||
=> GetClaimValue(user, InternalClaimTypes.Token);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a flag specifying whether the request is using an api key.
|
||||
/// </summary>
|
||||
/// <param name="user">Current claims principal.</param>
|
||||
/// <returns>The flag specifying whether the request is using an api key.</returns>
|
||||
public static bool GetIsApiKey(this ClaimsPrincipal user)
|
||||
{
|
||||
var claimValue = GetClaimValue(user, InternalClaimTypes.IsApiKey);
|
||||
return bool.TryParse(claimValue, out var parsedClaimValue)
|
||||
&& parsedClaimValue;
|
||||
}
|
||||
|
||||
private static string? GetClaimValue(in ClaimsPrincipal user, string name)
|
||||
=> user.Claims.FirstOrDefault(claim => claim.Type.Equals(name, StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Generic;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace Jellyfin.Api.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Dto Extensions.
|
||||
/// </summary>
|
||||
public static class DtoExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add additional DtoOptions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Converted from IHasDtoOptions.
|
||||
/// Legacy order: 3.
|
||||
/// </remarks>
|
||||
/// <param name="dtoOptions">DtoOptions object.</param>
|
||||
/// <param name="enableImages">Enable images.</param>
|
||||
/// <param name="enableUserData">Enable user data.</param>
|
||||
/// <param name="imageTypeLimit">Image type limit.</param>
|
||||
/// <param name="enableImageTypes">Enable image types.</param>
|
||||
/// <returns>Modified DtoOptions object.</returns>
|
||||
internal static DtoOptions AddAdditionalDtoOptions(
|
||||
this DtoOptions dtoOptions,
|
||||
bool? enableImages,
|
||||
bool? enableUserData,
|
||||
int? imageTypeLimit,
|
||||
IReadOnlyList<ImageType> enableImageTypes)
|
||||
{
|
||||
dtoOptions.EnableImages = enableImages ?? true;
|
||||
|
||||
if (imageTypeLimit.HasValue)
|
||||
{
|
||||
dtoOptions.ImageTypeLimit = imageTypeLimit.Value;
|
||||
}
|
||||
|
||||
if (enableUserData.HasValue)
|
||||
{
|
||||
dtoOptions.EnableUserData = enableUserData.Value;
|
||||
}
|
||||
|
||||
if (enableImageTypes.Count != 0)
|
||||
{
|
||||
dtoOptions.ImageTypes = enableImageTypes;
|
||||
}
|
||||
|
||||
return dtoOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Jellyfin.Extensions.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Api.Formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Camel Case Json Profile Formatter.
|
||||
/// </summary>
|
||||
public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class.
|
||||
/// </summary>
|
||||
public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCaseOptions)
|
||||
{
|
||||
SupportedMediaTypes.Clear();
|
||||
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.CamelCaseMediaType));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Net.Mime;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
|
||||
namespace Jellyfin.Api.Formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Css output formatter.
|
||||
/// </summary>
|
||||
public sealed class CssOutputFormatter : StringOutputFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CssOutputFormatter"/> class.
|
||||
/// </summary>
|
||||
public CssOutputFormatter()
|
||||
{
|
||||
SupportedMediaTypes.Clear();
|
||||
SupportedMediaTypes.Add(MediaTypeNames.Text.Css);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Net.Mime;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Api.Formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Pascal Case Json Profile Formatter.
|
||||
/// </summary>
|
||||
public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class.
|
||||
/// </summary>
|
||||
public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCaseOptions)
|
||||
{
|
||||
SupportedMediaTypes.Clear();
|
||||
// Add application/json for default formatter
|
||||
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json));
|
||||
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.PascalCaseMediaType));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||
|
||||
namespace Jellyfin.Api.Formatters;
|
||||
|
||||
/// <summary>
|
||||
/// Xml output formatter.
|
||||
/// </summary>
|
||||
public sealed class XmlOutputFormatter : TextOutputFormatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
|
||||
/// </summary>
|
||||
public XmlOutputFormatter()
|
||||
{
|
||||
SupportedMediaTypes.Clear();
|
||||
SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
|
||||
|
||||
SupportedEncodings.Add(Encoding.UTF8);
|
||||
SupportedEncodings.Add(Encoding.Unicode);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(selectedEncoding);
|
||||
|
||||
var valueAsString = context.Object?.ToString();
|
||||
if (string.IsNullOrEmpty(valueAsString))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var response = context.HttpContext.Response;
|
||||
await response.WriteAsync(valueAsString, selectedEncoding).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using MediaBrowser.Model.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Api.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Audio helper.
|
||||
/// </summary>
|
||||
public class AudioHelper
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly ITranscodeManager _transcodeManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly EncodingHelper _encodingHelper;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AudioHelper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="transcodeManager">Instance of <see cref="ITranscodeManager"/> interface.</param>
|
||||
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
|
||||
/// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
public AudioHelper(
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
ITranscodeManager transcodeManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
EncodingHelper encodingHelper)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_transcodeManager = transcodeManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_encodingHelper = encodingHelper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get audio stream.
|
||||
/// </summary>
|
||||
/// <param name="transcodingJobType">Transcoding job type.</param>
|
||||
/// <param name="streamingRequest">Streaming controller.Request dto.</param>
|
||||
/// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns>
|
||||
public async Task<ActionResult> GetAudioStream(
|
||||
TranscodingJobType transcodingJobType,
|
||||
StreamingRequestDto streamingRequest)
|
||||
{
|
||||
if (_httpContextAccessor.HttpContext is null)
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext));
|
||||
}
|
||||
|
||||
bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head;
|
||||
|
||||
// CTS lifecycle is managed internally.
|
||||
var cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
using var state = await StreamingHelpers.GetStreamingState(
|
||||
streamingRequest,
|
||||
_httpContextAccessor.HttpContext,
|
||||
_mediaSourceManager,
|
||||
_userManager,
|
||||
_libraryManager,
|
||||
_serverConfigurationManager,
|
||||
_mediaEncoder,
|
||||
_encodingHelper,
|
||||
_transcodeManager,
|
||||
transcodingJobType,
|
||||
cancellationTokenSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (streamingRequest.Static && state.DirectStreamProvider is not null)
|
||||
{
|
||||
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId);
|
||||
if (liveStreamInfo is null)
|
||||
{
|
||||
throw new FileNotFoundException();
|
||||
}
|
||||
|
||||
var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream());
|
||||
// TODO (moved from MediaBrowser.Api): Don't hardcode contentType
|
||||
return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file.ts"));
|
||||
}
|
||||
|
||||
// Static remote stream
|
||||
if (streamingRequest.Static && state.InputProtocol == MediaProtocol.Http)
|
||||
{
|
||||
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, _httpContextAccessor.HttpContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (streamingRequest.Static && state.InputProtocol != MediaProtocol.File)
|
||||
{
|
||||
return new BadRequestObjectResult($"Input protocol {state.InputProtocol} cannot be streamed statically");
|
||||
}
|
||||
|
||||
var outputPath = state.OutputFilePath;
|
||||
|
||||
// Static stream
|
||||
if (streamingRequest.Static)
|
||||
{
|
||||
var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
|
||||
|
||||
if (state.MediaSource.IsInfiniteStream)
|
||||
{
|
||||
var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodeManager);
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
|
||||
return FileStreamResponseHelpers.GetStaticFileResult(
|
||||
state.MediaPath,
|
||||
contentType);
|
||||
}
|
||||
|
||||
// Need to start ffmpeg (because media can't be returned directly)
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath);
|
||||
return await FileStreamResponseHelpers.GetTranscodedFile(
|
||||
state,
|
||||
isHeadRequest,
|
||||
_httpContextAccessor.HttpContext,
|
||||
_transcodeManager,
|
||||
ffmpegCommandLineArguments,
|
||||
transcodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Api.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic hls helper.
|
||||
/// </summary>
|
||||
public class DynamicHlsHelper
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly ITranscodeManager _transcodeManager;
|
||||
private readonly INetworkManager _networkManager;
|
||||
private readonly ILogger<DynamicHlsHelper> _logger;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly EncodingHelper _encodingHelper;
|
||||
private readonly ITrickplayManager _trickplayManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="transcodeManager">Instance of <see cref="ITranscodeManager"/>.</param>
|
||||
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param>
|
||||
/// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
/// <param name="trickplayManager">Instance of <see cref="ITrickplayManager"/>.</param>
|
||||
public DynamicHlsHelper(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
ITranscodeManager transcodeManager,
|
||||
INetworkManager networkManager,
|
||||
ILogger<DynamicHlsHelper> logger,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
EncodingHelper encodingHelper,
|
||||
ITrickplayManager trickplayManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_transcodeManager = transcodeManager;
|
||||
_networkManager = networkManager;
|
||||
_logger = logger;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_encodingHelper = encodingHelper;
|
||||
_trickplayManager = trickplayManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get master hls playlist.
|
||||
/// </summary>
|
||||
/// <param name="transcodingJobType">Transcoding job type.</param>
|
||||
/// <param name="streamingRequest">Streaming request dto.</param>
|
||||
/// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
|
||||
/// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns>
|
||||
public async Task<ActionResult> GetMasterHlsPlaylist(
|
||||
TranscodingJobType transcodingJobType,
|
||||
StreamingRequestDto streamingRequest,
|
||||
bool enableAdaptiveBitrateStreaming)
|
||||
{
|
||||
var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head;
|
||||
// CTS lifecycle is managed internally.
|
||||
var cancellationTokenSource = new CancellationTokenSource();
|
||||
return await GetMasterPlaylistInternal(
|
||||
streamingRequest,
|
||||
isHeadRequest,
|
||||
enableAdaptiveBitrateStreaming,
|
||||
transcodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<ActionResult> GetMasterPlaylistInternal(
|
||||
StreamingRequestDto streamingRequest,
|
||||
bool isHeadRequest,
|
||||
bool enableAdaptiveBitrateStreaming,
|
||||
TranscodingJobType transcodingJobType,
|
||||
CancellationTokenSource cancellationTokenSource)
|
||||
{
|
||||
if (_httpContextAccessor.HttpContext is null)
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext));
|
||||
}
|
||||
|
||||
using var state = await StreamingHelpers.GetStreamingState(
|
||||
streamingRequest,
|
||||
_httpContextAccessor.HttpContext,
|
||||
_mediaSourceManager,
|
||||
_userManager,
|
||||
_libraryManager,
|
||||
_serverConfigurationManager,
|
||||
_mediaEncoder,
|
||||
_encodingHelper,
|
||||
_transcodeManager,
|
||||
transcodingJobType,
|
||||
cancellationTokenSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_httpContextAccessor.HttpContext.Response.Headers.Append(HeaderNames.Expires, "0");
|
||||
if (isHeadRequest)
|
||||
{
|
||||
return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8"));
|
||||
}
|
||||
|
||||
var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0);
|
||||
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.AppendLine("#EXTM3U");
|
||||
|
||||
var isLiveStream = state.IsSegmentedLiveStream;
|
||||
|
||||
var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString();
|
||||
|
||||
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
||||
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
||||
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
||||
}
|
||||
|
||||
// from universal audio service
|
||||
if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||
&& !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
queryString += "&SegmentContainer=" + state.Request.SegmentContainer;
|
||||
}
|
||||
|
||||
// from universal audio service
|
||||
if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons)
|
||||
&& !queryString.Contains("TranscodeReasons=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
||||
}
|
||||
|
||||
// Video rotation metadata is only supported in fMP4 remuxing
|
||||
if (state.VideoStream is not null
|
||||
&& state.VideoRequest is not null
|
||||
&& (state.VideoStream?.Rotation ?? 0) != 0
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
queryString += "&AllowVideoStreamCopy=false";
|
||||
}
|
||||
|
||||
// Main stream
|
||||
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||
var playlistUrl = baseUrl + queryString;
|
||||
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
|
||||
var subtitleStreams = state.MediaSource
|
||||
.MediaStreams
|
||||
.Where(i => i.IsTextSubtitleStream)
|
||||
.ToList();
|
||||
|
||||
var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest)
|
||||
? "subs"
|
||||
: null;
|
||||
|
||||
// If we're burning in subtitles then don't add additional subs to the manifest
|
||||
if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
|
||||
{
|
||||
subtitleGroup = null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitleGroup))
|
||||
{
|
||||
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
||||
}
|
||||
|
||||
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
||||
|
||||
if (state.VideoStream is not null && state.VideoRequest is not null)
|
||||
{
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
|
||||
// Provide AV1 and HEVC SDR entrances for backward compatibility.
|
||||
foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
|
||||
{
|
||||
var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
|
||||
&& string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
|
||||
var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding
|
||||
&& string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
|
||||
var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed;
|
||||
|
||||
if (isEncodingAllowed
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||
{
|
||||
// Force AV1 and HEVC Main Profile and disable video stream copy.
|
||||
state.OutputVideoCodec = sdrVideoCodec;
|
||||
|
||||
var sdrPlaylistQuery = playlistQuery;
|
||||
sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
|
||||
sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
|
||||
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||
|
||||
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||
|
||||
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||
|
||||
// Restore the video codec
|
||||
state.OutputVideoCodec = "copy";
|
||||
}
|
||||
}
|
||||
|
||||
// Provide H.264 SDR entrance for backward compatibility.
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||
{
|
||||
// Force H.264 and disable video stream copy.
|
||||
state.OutputVideoCodec = "h264";
|
||||
|
||||
var sdrPlaylistQuery = playlistQuery;
|
||||
sdrPlaylistQuery["VideoCodec"] = "h264";
|
||||
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||
|
||||
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||
|
||||
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||
|
||||
// Restore the video codec
|
||||
state.OutputVideoCodec = "copy";
|
||||
}
|
||||
|
||||
// Provide Level 5.0 entrance for backward compatibility.
|
||||
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
|
||||
// but in fact it is capable of playing videos up to Level 6.1.
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.Level.HasValue
|
||||
&& state.VideoStream.Level > 150
|
||||
&& state.VideoStream.VideoRange == VideoRange.SDR
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var playlistCodecsField = new StringBuilder();
|
||||
AppendPlaylistCodecsField(playlistCodecsField, state);
|
||||
|
||||
// Force the video level to 5.0.
|
||||
var originalLevel = state.VideoStream.Level;
|
||||
state.VideoStream.Level = 150;
|
||||
var newPlaylistCodecsField = new StringBuilder();
|
||||
AppendPlaylistCodecsField(newPlaylistCodecsField, state);
|
||||
|
||||
// Restore the video level.
|
||||
state.VideoStream.Level = originalLevel;
|
||||
var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField);
|
||||
builder.Append(newPlaylist);
|
||||
}
|
||||
}
|
||||
|
||||
if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIP()))
|
||||
{
|
||||
var requestedVideoBitrate = state.VideoRequest?.VideoBitRate ?? 0;
|
||||
|
||||
// By default, vary by just 200k
|
||||
var variation = GetBitrateVariation(totalBitrate);
|
||||
|
||||
var newBitrate = totalBitrate - variation;
|
||||
var variantQuery = playlistQuery;
|
||||
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||
var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||
|
||||
variation *= 2;
|
||||
newBitrate = totalBitrate - variation;
|
||||
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||
variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||
}
|
||||
|
||||
if (!isLiveStream && (state.VideoRequest?.EnableTrickplay ?? false))
|
||||
{
|
||||
var sourceId = Guid.Parse(state.Request.MediaSourceId);
|
||||
var trickplayResolutions = await _trickplayManager.GetTrickplayResolutions(sourceId).ConfigureAwait(false);
|
||||
AddTrickplay(state, trickplayResolutions, builder, _httpContextAccessor.HttpContext.User);
|
||||
}
|
||||
|
||||
return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
|
||||
}
|
||||
|
||||
private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
|
||||
{
|
||||
var playlistBuilder = new StringBuilder();
|
||||
playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
|
||||
.Append(bitrate.ToString(CultureInfo.InvariantCulture))
|
||||
.Append(",AVERAGE-BANDWIDTH=")
|
||||
.Append(bitrate.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
AppendPlaylistVideoRangeField(playlistBuilder, state);
|
||||
|
||||
AppendPlaylistCodecsField(playlistBuilder, state);
|
||||
|
||||
AppendPlaylistSupplementalCodecsField(playlistBuilder, state);
|
||||
|
||||
AppendPlaylistResolutionField(playlistBuilder, state);
|
||||
|
||||
AppendPlaylistFramerateField(playlistBuilder, state);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitleGroup))
|
||||
{
|
||||
playlistBuilder.Append(",SUBTITLES=\"")
|
||||
.Append(subtitleGroup)
|
||||
.Append('"');
|
||||
}
|
||||
|
||||
playlistBuilder.Append(Environment.NewLine);
|
||||
playlistBuilder.AppendLine(url);
|
||||
builder.Append(playlistBuilder);
|
||||
|
||||
return playlistBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a VIDEO-RANGE field containing the range of the output video stream.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state)
|
||||
{
|
||||
if (state.VideoStream is not null && state.VideoStream.VideoRange != VideoRange.Unknown)
|
||||
{
|
||||
var videoRange = state.VideoStream.VideoRange;
|
||||
var videoRangeType = state.VideoStream.VideoRangeType;
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
|
||||
{
|
||||
if (videoRange == VideoRange.SDR)
|
||||
{
|
||||
builder.Append(",VIDEO-RANGE=SDR");
|
||||
}
|
||||
|
||||
if (videoRange == VideoRange.HDR)
|
||||
{
|
||||
switch (videoRangeType)
|
||||
{
|
||||
case VideoRangeType.HLG:
|
||||
case VideoRangeType.DOVIWithHLG:
|
||||
builder.Append(",VIDEO-RANGE=HLG");
|
||||
break;
|
||||
default:
|
||||
builder.Append(",VIDEO-RANGE=PQ");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Currently we only encode to SDR.
|
||||
builder.Append(",VIDEO-RANGE=SDR");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a CODECS field containing formatted strings of
|
||||
/// the active streams output video and audio codecs.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
|
||||
/// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
|
||||
/// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state)
|
||||
{
|
||||
// Video
|
||||
string videoCodecs = string.Empty;
|
||||
int? videoCodecLevel = GetOutputVideoCodecLevel(state);
|
||||
if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue)
|
||||
{
|
||||
videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value);
|
||||
}
|
||||
|
||||
// Audio
|
||||
string audioCodecs = string.Empty;
|
||||
if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec))
|
||||
{
|
||||
audioCodecs = GetPlaylistAudioCodecs(state);
|
||||
}
|
||||
|
||||
StringBuilder codecs = new StringBuilder();
|
||||
|
||||
codecs.Append(videoCodecs);
|
||||
|
||||
if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs))
|
||||
{
|
||||
codecs.Append(',');
|
||||
}
|
||||
|
||||
codecs.Append(audioCodecs);
|
||||
|
||||
if (codecs.Length > 1)
|
||||
{
|
||||
builder.Append(",CODECS=\"")
|
||||
.Append(codecs)
|
||||
.Append('"');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a SUPPLEMENTAL-CODECS field containing formatted strings of
|
||||
/// the active streams output Dolby Vision Videos.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
|
||||
/// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
private void AppendPlaylistSupplementalCodecsField(StringBuilder builder, StreamState state)
|
||||
{
|
||||
// HDR dynamic metadata currently cannot exist when transcoding
|
||||
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (EncodingHelper.IsDovi(state.VideoStream) && !_encodingHelper.IsDoviRemoved(state))
|
||||
{
|
||||
AppendDvString();
|
||||
}
|
||||
else if (EncodingHelper.IsHdr10Plus(state.VideoStream) && !_encodingHelper.IsHdr10PlusRemoved(state))
|
||||
{
|
||||
AppendHdr10PlusString();
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
void AppendDvString()
|
||||
{
|
||||
var dvProfile = state.VideoStream.DvProfile;
|
||||
var dvLevel = state.VideoStream.DvLevel;
|
||||
var dvRangeString = state.VideoStream.VideoRangeType switch
|
||||
{
|
||||
VideoRangeType.DOVIWithHDR10 => "db1p",
|
||||
VideoRangeType.DOVIWithHLG => "db4h",
|
||||
VideoRangeType.DOVIWithHDR10Plus => "db1p", // The HDR10+ metadata would be removed if Dovi metadata is not removed
|
||||
_ => string.Empty // Don't label Dovi with EL and SDR due to compatability issues, ignore invalid configurations
|
||||
};
|
||||
|
||||
if (dvProfile is null || dvLevel is null || string.IsNullOrEmpty(dvRangeString))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dvFourCc = string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase) ? "dav1" : "dvh1";
|
||||
builder.Append(",SUPPLEMENTAL-CODECS=\"")
|
||||
.Append(dvFourCc)
|
||||
.Append('.')
|
||||
.Append(dvProfile.Value.ToString("D2", CultureInfo.InvariantCulture))
|
||||
.Append('.')
|
||||
.Append(dvLevel.Value.ToString("D2", CultureInfo.InvariantCulture))
|
||||
.Append('/')
|
||||
.Append(dvRangeString)
|
||||
.Append('"');
|
||||
}
|
||||
|
||||
void AppendHdr10PlusString()
|
||||
{
|
||||
var videoCodecLevel = GetOutputVideoCodecLevel(state);
|
||||
if (string.IsNullOrEmpty(state.ActualOutputVideoCodec) || videoCodecLevel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var videoCodecString = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value);
|
||||
builder.Append(",SUPPLEMENTAL-CODECS=\"")
|
||||
.Append(videoCodecString)
|
||||
.Append('/')
|
||||
.Append("cdm4")
|
||||
.Append('"');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a RESOLUTION field containing the resolution of the output stream.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state)
|
||||
{
|
||||
if (state.OutputWidth.HasValue && state.OutputHeight.HasValue)
|
||||
{
|
||||
builder.Append(",RESOLUTION=")
|
||||
.Append(state.OutputWidth.GetValueOrDefault())
|
||||
.Append('x')
|
||||
.Append(state.OutputHeight.GetValueOrDefault());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a FRAME-RATE field containing the framerate of the output stream.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state)
|
||||
{
|
||||
double? framerate = null;
|
||||
if (state.TargetFramerate.HasValue)
|
||||
{
|
||||
framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3);
|
||||
}
|
||||
else if (state.VideoStream?.RealFrameRate is not null)
|
||||
{
|
||||
framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3);
|
||||
}
|
||||
|
||||
if (framerate.HasValue)
|
||||
{
|
||||
builder.Append(",FRAME-RATE=")
|
||||
.Append(framerate.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, IPAddress ipAddress)
|
||||
{
|
||||
// Within the local network this will likely do more harm than good.
|
||||
if (_networkManager.IsInLocalNetwork(ipAddress))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!enableAdaptiveBitrateStreaming)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath))
|
||||
{
|
||||
// Opening live streams is so slow it's not even worth it
|
||||
return false;
|
||||
}
|
||||
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state.IsOutputVideo)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return state.VideoRequest?.VideoBitRate.HasValue ?? false;
|
||||
}
|
||||
|
||||
private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder, ClaimsPrincipal user)
|
||||
{
|
||||
if (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Drop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedIndex = state.SubtitleStream is null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index;
|
||||
const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\"";
|
||||
|
||||
foreach (var stream in subtitles)
|
||||
{
|
||||
var name = stream.DisplayTitle;
|
||||
|
||||
var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index;
|
||||
var isForced = stream.IsForced;
|
||||
|
||||
var url = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&ApiKey={3}",
|
||||
state.Request.MediaSourceId,
|
||||
stream.Index.ToString(CultureInfo.InvariantCulture),
|
||||
30.ToString(CultureInfo.InvariantCulture),
|
||||
user.GetToken());
|
||||
|
||||
var line = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
Format,
|
||||
name,
|
||||
isDefault ? "YES" : "NO",
|
||||
isForced ? "YES" : "NO",
|
||||
url,
|
||||
stream.Language ?? "Unknown");
|
||||
|
||||
builder.AppendLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends EXT-X-IMAGE-STREAM-INF playlists for each available trickplay resolution.
|
||||
/// </summary>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
/// <param name="trickplayResolutions">Dictionary of widths to corresponding tiles info.</param>
|
||||
/// <param name="builder">StringBuilder to append the field to.</param>
|
||||
/// <param name="user">Http user context.</param>
|
||||
private void AddTrickplay(StreamState state, Dictionary<int, TrickplayInfo> trickplayResolutions, StringBuilder builder, ClaimsPrincipal user)
|
||||
{
|
||||
const string playlistFormat = "#EXT-X-IMAGE-STREAM-INF:BANDWIDTH={0},RESOLUTION={1}x{2},CODECS=\"jpeg\",URI=\"{3}\"";
|
||||
|
||||
foreach (var resolution in trickplayResolutions)
|
||||
{
|
||||
var width = resolution.Key;
|
||||
var trickplayInfo = resolution.Value;
|
||||
|
||||
var url = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Trickplay/{0}/tiles.m3u8?MediaSourceId={1}&ApiKey={2}",
|
||||
width.ToString(CultureInfo.InvariantCulture),
|
||||
state.Request.MediaSourceId,
|
||||
user.GetToken());
|
||||
|
||||
builder.AppendFormat(
|
||||
CultureInfo.InvariantCulture,
|
||||
playlistFormat,
|
||||
trickplayInfo.Bandwidth.ToString(CultureInfo.InvariantCulture),
|
||||
trickplayInfo.Width.ToString(CultureInfo.InvariantCulture),
|
||||
trickplayInfo.Height.ToString(CultureInfo.InvariantCulture),
|
||||
url);
|
||||
|
||||
builder.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the H.26X level of the output video stream.
|
||||
/// </summary>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
/// <returns>H.26X level of the output video stream.</returns>
|
||||
private int? GetOutputVideoCodecLevel(StreamState state)
|
||||
{
|
||||
string levelString = string.Empty;
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream is not null
|
||||
&& state.VideoStream.Level.HasValue)
|
||||
{
|
||||
levelString = state.VideoStream.Level.Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41";
|
||||
levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120";
|
||||
levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
levelString = state.GetRequestedLevel("av1") ?? "19";
|
||||
levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
|
||||
}
|
||||
}
|
||||
|
||||
if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
|
||||
{
|
||||
return parsedLevel;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the profile of the output video stream.
|
||||
/// </summary>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
/// <param name="codec">Video codec.</param>
|
||||
/// <returns>Profile of the output video stream.</returns>
|
||||
private string GetOutputVideoCodecProfile(StreamState state, string codec)
|
||||
{
|
||||
string profileString = string.Empty;
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& !string.IsNullOrEmpty(state.VideoStream.Profile))
|
||||
{
|
||||
profileString = state.VideoStream.Profile;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(codec))
|
||||
{
|
||||
profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty;
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
profileString ??= "high";
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
profileString ??= "main";
|
||||
}
|
||||
}
|
||||
|
||||
return profileString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a formatted string of the output audio codec, for use in the CODECS field.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
|
||||
/// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
/// <returns>Formatted audio codec string.</returns>
|
||||
private string GetPlaylistAudioCodecs(StreamState state)
|
||||
{
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
|
||||
? state.AudioStream?.Profile : state.GetRequestedProfiles("aac").FirstOrDefault();
|
||||
|
||||
return HlsCodecStringHelpers.GetAACString(profile);
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetMP3String();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetAC3String();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetEAC3String();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetFLACString();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetALACString();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetOPUSString();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetTRUEHDString();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// lavc only support encoding DTS core profile
|
||||
string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) ? state.AudioStream?.Profile : "DTS";
|
||||
|
||||
return HlsCodecStringHelpers.GetDTSString(profile);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a formatted string of the output video codec, for use in the CODECS field.
|
||||
/// </summary>
|
||||
/// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
|
||||
/// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
|
||||
/// <param name="state">StreamState of the current stream.</param>
|
||||
/// <param name="codec">Video codec.</param>
|
||||
/// <param name="level">Video level.</param>
|
||||
/// <returns>Formatted video codec string.</returns>
|
||||
private string GetPlaylistVideoCodecs(StreamState state, string codec, int level)
|
||||
{
|
||||
if (level == 0)
|
||||
{
|
||||
// This is 0 when there's no requested level in the device profile
|
||||
// and the source is not encoded in H.26X or AV1
|
||||
_logger.LogError("Got invalid level when building CODECS field for HLS master playlist");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string profile = GetOutputVideoCodecProfile(state, "h264");
|
||||
return HlsCodecStringHelpers.GetH264String(profile, level);
|
||||
}
|
||||
|
||||
if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string profile = GetOutputVideoCodecProfile(state, "hevc");
|
||||
return HlsCodecStringHelpers.GetH265String(profile, level);
|
||||
}
|
||||
|
||||
if (string.Equals(codec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string profile = GetOutputVideoCodecProfile(state, "av1");
|
||||
|
||||
// Currently we only transcode to 8 bits AV1
|
||||
int bitDepth = 8;
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream is not null
|
||||
&& state.VideoStream.BitDepth.HasValue)
|
||||
{
|
||||
bitDepth = state.VideoStream.BitDepth.Value;
|
||||
}
|
||||
|
||||
return HlsCodecStringHelpers.GetAv1String(profile, level, false, bitDepth);
|
||||
}
|
||||
|
||||
// VP9 HLS is for video remuxing only, everything is probed from the original video
|
||||
if (string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var width = state.VideoStream.Width ?? 0;
|
||||
var height = state.VideoStream.Height ?? 0;
|
||||
var framerate = state.VideoStream.ReferenceFrameRate ?? 30;
|
||||
var bitDepth = state.VideoStream.BitDepth ?? 8;
|
||||
return HlsCodecStringHelpers.GetVp9String(
|
||||
width,
|
||||
height,
|
||||
state.VideoStream.PixelFormat,
|
||||
framerate,
|
||||
bitDepth);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private int GetBitrateVariation(int bitrate)
|
||||
{
|
||||
// By default, vary by just 50k
|
||||
var variation = 50000;
|
||||
|
||||
if (bitrate >= 10000000)
|
||||
{
|
||||
variation = 2000000;
|
||||
}
|
||||
else if (bitrate >= 5000000)
|
||||
{
|
||||
variation = 1500000;
|
||||
}
|
||||
else if (bitrate >= 3000000)
|
||||
{
|
||||
variation = 1000000;
|
||||
}
|
||||
else if (bitrate >= 2000000)
|
||||
{
|
||||
variation = 500000;
|
||||
}
|
||||
else if (bitrate >= 1000000)
|
||||
{
|
||||
variation = 300000;
|
||||
}
|
||||
else if (bitrate >= 600000)
|
||||
{
|
||||
variation = 200000;
|
||||
}
|
||||
else if (bitrate >= 400000)
|
||||
{
|
||||
variation = 100000;
|
||||
}
|
||||
|
||||
return variation;
|
||||
}
|
||||
|
||||
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
|
||||
{
|
||||
var oldPlaylist = playlist.ToString();
|
||||
return oldPlaylist.Replace(
|
||||
oldValue.ToString(),
|
||||
newValue.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Mime;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Api.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The stream response helpers.
|
||||
/// </summary>
|
||||
public static class FileStreamResponseHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a static file from a remote source.
|
||||
/// </summary>
|
||||
/// <param name="state">The current <see cref="StreamState"/>.</param>
|
||||
/// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param>
|
||||
/// <param name="httpContext">The current http context.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns>
|
||||
public static async Task<ActionResult> GetStaticRemoteStreamResult(
|
||||
StreamState state,
|
||||
HttpClient httpClient,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var requestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri(state.MediaPath));
|
||||
|
||||
// Forward User-Agent if provided
|
||||
if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
|
||||
{
|
||||
// Clear default and add specific one if exists, otherwise HttpClient default might be used
|
||||
requestMessage.Headers.UserAgent.Clear();
|
||||
requestMessage.Headers.TryAddWithoutValidation(HeaderNames.UserAgent, useragent);
|
||||
}
|
||||
|
||||
// Forward Range header if present in the client request
|
||||
if (httpContext.Request.Headers.TryGetValue(HeaderNames.Range, out var rangeValue))
|
||||
{
|
||||
var rangeString = rangeValue.ToString();
|
||||
if (!string.IsNullOrEmpty(rangeString))
|
||||
{
|
||||
requestMessage.Headers.Range = System.Net.Http.Headers.RangeHeaderValue.Parse(rangeString);
|
||||
}
|
||||
}
|
||||
|
||||
// Send the request to the upstream server
|
||||
// Use ResponseHeadersRead to avoid downloading the whole content immediately
|
||||
var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check if the upstream server supports range requests and acted upon our Range header
|
||||
bool upstreamSupportsRange = response.StatusCode == System.Net.HttpStatusCode.PartialContent;
|
||||
string acceptRangesValue = "none";
|
||||
if (response.Headers.TryGetValues(HeaderNames.AcceptRanges, out var acceptRangesHeaders))
|
||||
{
|
||||
// Prefer upstream server's Accept-Ranges header if available
|
||||
acceptRangesValue = string.Join(", ", acceptRangesHeaders);
|
||||
upstreamSupportsRange |= acceptRangesValue.Contains("bytes", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else if (upstreamSupportsRange) // If we got 206 but no Accept-Ranges header, assume bytes
|
||||
{
|
||||
acceptRangesValue = "bytes";
|
||||
}
|
||||
|
||||
// Set Accept-Ranges header for the client based on upstream support
|
||||
httpContext.Response.Headers[HeaderNames.AcceptRanges] = acceptRangesValue;
|
||||
|
||||
// Set Content-Range header if upstream provided it (implies partial content)
|
||||
if (response.Content.Headers.ContentRange is not null)
|
||||
{
|
||||
httpContext.Response.Headers[HeaderNames.ContentRange] = response.Content.Headers.ContentRange.ToString();
|
||||
}
|
||||
|
||||
// Set Content-Length header. For partial content, this is the length of the partial segment.
|
||||
if (response.Content.Headers.ContentLength.HasValue)
|
||||
{
|
||||
httpContext.Response.ContentLength = response.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
|
||||
// Set Content-Type header
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Application.Octet; // Use a more generic default
|
||||
|
||||
// Set the status code for the client response (e.g., 200 OK or 206 Partial Content)
|
||||
httpContext.Response.StatusCode = (int)response.StatusCode;
|
||||
|
||||
// Return the stream from the upstream server
|
||||
// IMPORTANT: Do not dispose the response stream here, FileStreamResult will handle it.
|
||||
return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a static file from the server.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to the file.</param>
|
||||
/// <param name="contentType">The content type of the file.</param>
|
||||
/// <returns>An <see cref="ActionResult"/> the file.</returns>
|
||||
public static ActionResult GetStaticFileResult(
|
||||
string path,
|
||||
string contentType)
|
||||
{
|
||||
return new PhysicalFileResult(path, contentType) { EnableRangeProcessing = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a transcoded file from the server.
|
||||
/// </summary>
|
||||
/// <param name="state">The current <see cref="StreamState"/>.</param>
|
||||
/// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
|
||||
/// <param name="httpContext">The current http context.</param>
|
||||
/// <param name="transcodeManager">The <see cref="ITranscodeManager"/> singleton.</param>
|
||||
/// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param>
|
||||
/// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
|
||||
/// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param>
|
||||
/// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns>
|
||||
public static async Task<ActionResult> GetTranscodedFile(
|
||||
StreamState state,
|
||||
bool isHeadRequest,
|
||||
HttpContext httpContext,
|
||||
ITranscodeManager transcodeManager,
|
||||
string ffmpegCommandLineArguments,
|
||||
TranscodingJobType transcodingJobType,
|
||||
CancellationTokenSource cancellationTokenSource)
|
||||
{
|
||||
// Use the command line args with a dummy playlist path
|
||||
var outputPath = state.OutputFilePath;
|
||||
|
||||
httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
|
||||
|
||||
var contentType = state.GetMimeType(outputPath);
|
||||
|
||||
// Headers only
|
||||
if (isHeadRequest)
|
||||
{
|
||||
httpContext.Response.Headers[HeaderNames.ContentType] = contentType;
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
using (await transcodeManager.LockAsync(outputPath, cancellationTokenSource.Token).ConfigureAwait(false))
|
||||
{
|
||||
TranscodingJob? job;
|
||||
if (!File.Exists(outputPath))
|
||||
{
|
||||
job = await transcodeManager.StartFfMpeg(
|
||||
state,
|
||||
outputPath,
|
||||
ffmpegCommandLineArguments,
|
||||
httpContext.User.GetUserId(),
|
||||
transcodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
job = transcodeManager.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive);
|
||||
state.Dispose();
|
||||
}
|
||||
|
||||
var stream = new ProgressiveFileStream(outputPath, job, transcodeManager);
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Jellyfin.Api.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers to generate HLS codec strings according to
|
||||
/// <a href="https://datatracker.ietf.org/doc/html/rfc6381#section-3.3">RFC 6381 section 3.3</a>
|
||||
/// and the <a href="https://mp4ra.org">MP4 Registration Authority</a>.
|
||||
/// </summary>
|
||||
public static class HlsCodecStringHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Codec name for MP3.
|
||||
/// </summary>
|
||||
public const string MP3 = "mp4a.40.34";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for AC-3.
|
||||
/// </summary>
|
||||
public const string AC3 = "ac-3";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for E-AC-3.
|
||||
/// </summary>
|
||||
public const string EAC3 = "ec-3";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for FLAC.
|
||||
/// </summary>
|
||||
public const string FLAC = "fLaC";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for ALAC.
|
||||
/// </summary>
|
||||
public const string ALAC = "alac";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for OPUS.
|
||||
/// </summary>
|
||||
public const string OPUS = "Opus";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for TRUEHD.
|
||||
/// </summary>
|
||||
public const string TRUEHD = "mlpa";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a MP3 codec string.
|
||||
/// </summary>
|
||||
/// <returns>MP3 codec string.</returns>
|
||||
public static string GetMP3String()
|
||||
{
|
||||
return MP3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an AAC codec string.
|
||||
/// </summary>
|
||||
/// <param name="profile">AAC profile.</param>
|
||||
/// <returns>AAC codec string.</returns>
|
||||
public static string GetAACString(string? profile)
|
||||
{
|
||||
StringBuilder result = new StringBuilder("mp4a", 9);
|
||||
|
||||
if (string.Equals(profile, "HE-AAC", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".40.5");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to LC if profile is invalid
|
||||
result.Append(".40.2");
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an AC-3 codec string.
|
||||
/// </summary>
|
||||
/// <returns>AC-3 codec string.</returns>
|
||||
public static string GetAC3String()
|
||||
{
|
||||
return AC3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an E-AC-3 codec string.
|
||||
/// </summary>
|
||||
/// <returns>E-AC-3 codec string.</returns>
|
||||
public static string GetEAC3String()
|
||||
{
|
||||
return EAC3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an FLAC codec string.
|
||||
/// </summary>
|
||||
/// <returns>FLAC codec string.</returns>
|
||||
public static string GetFLACString()
|
||||
{
|
||||
return FLAC;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an ALAC codec string.
|
||||
/// </summary>
|
||||
/// <returns>ALAC codec string.</returns>
|
||||
public static string GetALACString()
|
||||
{
|
||||
return ALAC;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an OPUS codec string.
|
||||
/// </summary>
|
||||
/// <returns>OPUS codec string.</returns>
|
||||
public static string GetOPUSString()
|
||||
{
|
||||
return OPUS;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an TRUEHD codec string.
|
||||
/// </summary>
|
||||
/// <returns>TRUEHD codec string.</returns>
|
||||
public static string GetTRUEHDString()
|
||||
{
|
||||
return TRUEHD;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an DTS codec string.
|
||||
/// </summary>
|
||||
/// <param name="profile">DTS profile.</param>
|
||||
/// <returns>DTS codec string.</returns>
|
||||
public static string GetDTSString(string? profile)
|
||||
{
|
||||
if (string.Equals(profile, "DTS", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "DTS-ES", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "DTS 96/24", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "dtsc";
|
||||
}
|
||||
|
||||
if (string.Equals(profile, "DTS-HD HRA", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "DTS-HD MA", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "DTS-HD MA + DTS:X", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "DTS-HD MA + DTS:X IMAX", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "dtsh";
|
||||
}
|
||||
|
||||
if (string.Equals(profile, "DTS Express", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "dtse";
|
||||
}
|
||||
|
||||
// Default to DTS core if profile is invalid
|
||||
return "dtsc";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a H.264 codec string.
|
||||
/// </summary>
|
||||
/// <param name="profile">H.264 profile.</param>
|
||||
/// <param name="level">H.264 level.</param>
|
||||
/// <returns>H.264 string.</returns>
|
||||
public static string GetH264String(string? profile, int level)
|
||||
{
|
||||
StringBuilder result = new StringBuilder("avc1", 11);
|
||||
|
||||
if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".6400");
|
||||
}
|
||||
else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".4D40");
|
||||
}
|
||||
else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".42E0");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to constrained baseline if profile is invalid
|
||||
result.Append(".4240");
|
||||
}
|
||||
|
||||
string levelHex = level.ToString("X2", CultureInfo.InvariantCulture);
|
||||
result.Append(levelHex);
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a H.265 codec string.
|
||||
/// </summary>
|
||||
/// <param name="profile">H.265 profile.</param>
|
||||
/// <param name="level">H.265 level.</param>
|
||||
/// <returns>H.265 string.</returns>
|
||||
public static string GetH265String(string? profile, int level)
|
||||
{
|
||||
// The h265 syntax is a bit of a mystery at the time this comment was written.
|
||||
// This is what I've found through various sources:
|
||||
// FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
|
||||
StringBuilder result = new StringBuilder("hvc1", 16);
|
||||
|
||||
if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".2.4");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to main if profile is invalid
|
||||
result.Append(".1.4");
|
||||
}
|
||||
|
||||
result.Append(".L")
|
||||
.Append(level)
|
||||
.Append(".B0");
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a VP9 codec string.
|
||||
/// </summary>
|
||||
/// <param name="width">Video width.</param>
|
||||
/// <param name="height">Video height.</param>
|
||||
/// <param name="pixelFormat">Video pixel format.</param>
|
||||
/// <param name="framerate">Video framerate.</param>
|
||||
/// <param name="bitDepth">Video bitDepth.</param>
|
||||
/// <returns>The VP9 codec string.</returns>
|
||||
public static string GetVp9String(int width, int height, string pixelFormat, float framerate, int bitDepth)
|
||||
{
|
||||
// refer: https://www.webmproject.org/vp9/mp4/
|
||||
StringBuilder result = new StringBuilder("vp09", 13);
|
||||
|
||||
var profileString = pixelFormat switch
|
||||
{
|
||||
"yuv420p" => "00",
|
||||
"yuvj420p" => "00",
|
||||
"yuv422p" => "01",
|
||||
"yuv444p" => "01",
|
||||
"yuv420p10le" => "02",
|
||||
"yuv420p12le" => "02",
|
||||
"yuv422p10le" => "03",
|
||||
"yuv422p12le" => "03",
|
||||
"yuv444p10le" => "03",
|
||||
"yuv444p12le" => "03",
|
||||
_ => "00"
|
||||
};
|
||||
|
||||
var lumaPictureSize = width * height;
|
||||
var lumaSampleRate = lumaPictureSize * framerate;
|
||||
var levelString = lumaPictureSize switch
|
||||
{
|
||||
<= 0 => "00",
|
||||
<= 36864 => "10",
|
||||
<= 73728 => "11",
|
||||
<= 122880 => "20",
|
||||
<= 245760 => "21",
|
||||
<= 552960 => "30",
|
||||
<= 983040 => "31",
|
||||
<= 2228224 => lumaSampleRate <= 83558400 ? "40" : "41",
|
||||
<= 8912896 => lumaSampleRate <= 311951360 ? "50" : (lumaSampleRate <= 588251136 ? "51" : "52"),
|
||||
<= 35651584 => lumaSampleRate <= 1176502272 ? "60" : (lumaSampleRate <= 4706009088 ? "61" : "62"),
|
||||
_ => "00" // This should not happen
|
||||
};
|
||||
|
||||
if (bitDepth != 8
|
||||
&& bitDepth != 10
|
||||
&& bitDepth != 12)
|
||||
{
|
||||
// Default to 8 bits
|
||||
bitDepth = 8;
|
||||
}
|
||||
|
||||
result.Append('.').Append(profileString).Append('.').Append(levelString);
|
||||
var bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture);
|
||||
result.Append('.')
|
||||
.Append(bitDepthD2);
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an AV1 codec string.
|
||||
/// </summary>
|
||||
/// <param name="profile">AV1 profile.</param>
|
||||
/// <param name="level">AV1 level.</param>
|
||||
/// <param name="tierFlag">AV1 tier flag.</param>
|
||||
/// <param name="bitDepth">AV1 bit depth.</param>
|
||||
/// <returns>The AV1 codec string.</returns>
|
||||
public static string GetAv1String(string? profile, int level, bool tierFlag, int bitDepth)
|
||||
{
|
||||
// https://aomediacodec.github.io/av1-isobmff/#codecsparam
|
||||
// FORMAT: [codecTag].[profile].[level][tier].[bitDepth]
|
||||
StringBuilder result = new StringBuilder("av01", 13);
|
||||
|
||||
if (string.Equals(profile, "Main", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".0");
|
||||
}
|
||||
else if (string.Equals(profile, "High", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".1");
|
||||
}
|
||||
else if (string.Equals(profile, "Professional", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".2");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to Main
|
||||
result.Append(".0");
|
||||
}
|
||||
|
||||
if (level is <= 0 or > 31)
|
||||
{
|
||||
// Default to the maximum defined level 6.3
|
||||
level = 19;
|
||||
}
|
||||
|
||||
if (bitDepth != 8
|
||||
&& bitDepth != 10
|
||||
&& bitDepth != 12)
|
||||
{
|
||||
// Default to 8 bits
|
||||
bitDepth = 8;
|
||||
}
|
||||
|
||||
result.Append('.')
|
||||
// Needed to pad it double digits; otherwise, browsers will reject the stream.
|
||||
.AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", level)
|
||||
.Append(tierFlag ? 'H' : 'M');
|
||||
|
||||
string bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture);
|
||||
result.Append('.')
|
||||
.Append(bitDepthD2);
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Models.StreamingDtos;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The hls helpers.
|
||||
/// </summary>
|
||||
public static class HlsHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Waits for a minimum number of segments to be available.
|
||||
/// </summary>
|
||||
/// <param name="playlist">The playlist string.</param>
|
||||
/// <param name="segmentCount">The segment count.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
|
||||
/// <returns>A <see cref="Task"/> indicating the waiting process.</returns>
|
||||
public static async Task WaitForMinimumSegmentCount(string playlist, int? segmentCount, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written
|
||||
var fileStream = new FileStream(
|
||||
playlist,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite,
|
||||
IODefaults.FileStreamBufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await using (fileStream.ConfigureAwait(false))
|
||||
{
|
||||
using var reader = new StreamReader(fileStream);
|
||||
var count = 0;
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
|
||||
{
|
||||
if (line.Contains("#EXTINF:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
count++;
|
||||
if (count >= segmentCount)
|
||||
{
|
||||
logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// May get an error if the file is locked
|
||||
}
|
||||
|
||||
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the #EXT-X-MAP string.
|
||||
/// </summary>
|
||||
/// <param name="outputPath">The output path of the file.</param>
|
||||
/// <param name="state">The <see cref="StreamState"/>.</param>
|
||||
/// <param name="isOsDepends">Get a normal string or depends on OS.</param>
|
||||
/// <returns>The string text of #EXT-X-MAP.</returns>
|
||||
public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
|
||||
var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
|
||||
var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
|
||||
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||
|
||||
// on Linux/Unix
|
||||
// #EXT-X-MAP:URI="prefix-1.mp4"
|
||||
var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension;
|
||||
if (!isOsDepends)
|
||||
{
|
||||
return fmp4InitFileName;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// on Windows
|
||||
// #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4"
|
||||
fmp4InitFileName = outputPrefix + "-1" + outputExtension;
|
||||
}
|
||||
|
||||
return fmp4InitFileName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hls playlist text.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to the playlist file.</param>
|
||||
/// <param name="state">The <see cref="StreamState"/>.</param>
|
||||
/// <returns>The playlist text as a string.</returns>
|
||||
public static string GetLivePlaylistText(string path, StreamState state)
|
||||
{
|
||||
var text = File.ReadAllText(path);
|
||||
|
||||
var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
|
||||
if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fmp4InitFileName = GetFmp4InitFileName(path, state, true);
|
||||
var baseUrlParam = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"hls/{0}/",
|
||||
Path.GetFileNameWithoutExtension(path));
|
||||
var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false);
|
||||
|
||||
// Replace fMP4 init file URI.
|
||||
text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Media info helper.
|
||||
/// </summary>
|
||||
public class MediaInfoHelper
|
||||
{
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
private readonly ILogger<MediaInfoHelper> _logger;
|
||||
private readonly INetworkManager _networkManager;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaInfoHelper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param>
|
||||
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
|
||||
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
|
||||
public MediaInfoHelper(
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
ILogger<MediaInfoHelper> logger,
|
||||
INetworkManager networkManager,
|
||||
IDeviceManager deviceManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_serverConfigurationManager = serverConfigurationManager;
|
||||
_logger = logger;
|
||||
_networkManager = networkManager;
|
||||
_deviceManager = deviceManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get playback info.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="mediaSourceId">Media source id.</param>
|
||||
/// <param name="liveStreamId">Live stream id.</param>
|
||||
/// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns>
|
||||
public async Task<PlaybackInfoResponse> GetPlaybackInfo(
|
||||
BaseItem item,
|
||||
User? user,
|
||||
string? mediaSourceId = null,
|
||||
string? liveStreamId = null)
|
||||
{
|
||||
var result = new PlaybackInfoResponse();
|
||||
|
||||
MediaSourceInfo[] mediaSources;
|
||||
if (string.IsNullOrWhiteSpace(liveStreamId))
|
||||
{
|
||||
// TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
|
||||
var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mediaSourceId))
|
||||
{
|
||||
mediaSources = mediaSourcesList.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
mediaSources = mediaSourcesList
|
||||
.Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
mediaSources = new[] { mediaSource };
|
||||
}
|
||||
|
||||
if (mediaSources.Length == 0)
|
||||
{
|
||||
result.MediaSources = Array.Empty<MediaSourceInfo>();
|
||||
|
||||
result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it
|
||||
// Should we move this directly into MediaSourceManager?
|
||||
var mediaSourcesClone = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources));
|
||||
if (mediaSourcesClone is not null)
|
||||
{
|
||||
// Carry over the default audio index source.
|
||||
// This field is not intended to be exposed to API clients, but it is used internally by the server
|
||||
for (int i = 0; i < mediaSourcesClone.Length && i < mediaSources.Length; i++)
|
||||
{
|
||||
mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource;
|
||||
}
|
||||
|
||||
result.MediaSources = mediaSourcesClone;
|
||||
}
|
||||
|
||||
result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SetDeviceSpecificData.
|
||||
/// </summary>
|
||||
/// <param name="item">Item to set data for.</param>
|
||||
/// <param name="mediaSource">Media source info.</param>
|
||||
/// <param name="profile">Device profile.</param>
|
||||
/// <param name="claimsPrincipal">Current claims principal.</param>
|
||||
/// <param name="maxBitrate">Max bitrate.</param>
|
||||
/// <param name="startTimeTicks">Start time ticks.</param>
|
||||
/// <param name="mediaSourceId">Media source id.</param>
|
||||
/// <param name="audioStreamIndex">Audio stream index.</param>
|
||||
/// <param name="subtitleStreamIndex">Subtitle stream index.</param>
|
||||
/// <param name="maxAudioChannels">Max audio channels.</param>
|
||||
/// <param name="playSessionId">Play session id.</param>
|
||||
/// <param name="userId">User id.</param>
|
||||
/// <param name="enableDirectPlay">Enable direct play.</param>
|
||||
/// <param name="enableDirectStream">Enable direct stream.</param>
|
||||
/// <param name="enableTranscoding">Enable transcoding.</param>
|
||||
/// <param name="allowVideoStreamCopy">Allow video stream copy.</param>
|
||||
/// <param name="allowAudioStreamCopy">Allow audio stream copy.</param>
|
||||
/// <param name="alwaysBurnInSubtitleWhenTranscoding">Always burn-in subtitle when transcoding.</param>
|
||||
/// <param name="ipAddress">Requesting IP address.</param>
|
||||
public void SetDeviceSpecificData(
|
||||
BaseItem item,
|
||||
MediaSourceInfo mediaSource,
|
||||
DeviceProfile profile,
|
||||
ClaimsPrincipal claimsPrincipal,
|
||||
int? maxBitrate,
|
||||
long startTimeTicks,
|
||||
string mediaSourceId,
|
||||
int? audioStreamIndex,
|
||||
int? subtitleStreamIndex,
|
||||
int? maxAudioChannels,
|
||||
string playSessionId,
|
||||
Guid userId,
|
||||
bool enableDirectPlay,
|
||||
bool enableDirectStream,
|
||||
bool enableTranscoding,
|
||||
bool allowVideoStreamCopy,
|
||||
bool allowAudioStreamCopy,
|
||||
bool alwaysBurnInSubtitleWhenTranscoding,
|
||||
IPAddress ipAddress)
|
||||
{
|
||||
var streamBuilder = new StreamBuilder(_mediaEncoder, _logger);
|
||||
|
||||
var options = new MediaOptions
|
||||
{
|
||||
MediaSources = new[] { mediaSource },
|
||||
Context = EncodingContext.Streaming,
|
||||
DeviceId = claimsPrincipal.GetDeviceId(),
|
||||
ItemId = item.Id,
|
||||
Profile = profile,
|
||||
MaxAudioChannels = maxAudioChannels,
|
||||
AllowAudioStreamCopy = allowAudioStreamCopy,
|
||||
AllowVideoStreamCopy = allowVideoStreamCopy,
|
||||
AlwaysBurnInSubtitleWhenTranscoding = alwaysBurnInSubtitleWhenTranscoding,
|
||||
};
|
||||
|
||||
if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
options.MediaSourceId = mediaSourceId;
|
||||
options.AudioStreamIndex = audioStreamIndex;
|
||||
options.SubtitleStreamIndex = subtitleStreamIndex;
|
||||
}
|
||||
|
||||
var user = _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException();
|
||||
|
||||
if (!enableDirectPlay)
|
||||
{
|
||||
mediaSource.SupportsDirectPlay = false;
|
||||
}
|
||||
|
||||
if (!enableDirectStream || !allowVideoStreamCopy)
|
||||
{
|
||||
mediaSource.SupportsDirectStream = false;
|
||||
}
|
||||
|
||||
if (!enableTranscoding)
|
||||
{
|
||||
mediaSource.SupportsTranscoding = false;
|
||||
}
|
||||
|
||||
if (item is Audio)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"User policy for {0}. EnableAudioPlaybackTranscoding: {1}",
|
||||
user.Username,
|
||||
user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}",
|
||||
user.Username,
|
||||
user.HasPermission(PermissionKind.EnablePlaybackRemuxing),
|
||||
user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding),
|
||||
user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
|
||||
}
|
||||
|
||||
options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress);
|
||||
|
||||
if (!options.ForceDirectStream)
|
||||
{
|
||||
// direct-stream http streaming is currently broken
|
||||
options.EnableDirectStream = false;
|
||||
}
|
||||
|
||||
// Beginning of Playback Determination
|
||||
var streamInfo = item.MediaType == MediaType.Audio
|
||||
? streamBuilder.GetOptimalAudioStream(options)
|
||||
: streamBuilder.GetOptimalVideoStream(options);
|
||||
|
||||
if (streamInfo is not null)
|
||||
{
|
||||
streamInfo.PlaySessionId = playSessionId;
|
||||
streamInfo.StartPositionTicks = startTimeTicks;
|
||||
|
||||
mediaSource.SupportsDirectPlay = streamInfo.PlayMethod == PlayMethod.DirectPlay;
|
||||
|
||||
// Players do not handle this being set according to PlayMethod
|
||||
mediaSource.SupportsDirectStream =
|
||||
options.EnableDirectStream
|
||||
? streamInfo.PlayMethod == PlayMethod.DirectPlay || streamInfo.PlayMethod == PlayMethod.DirectStream
|
||||
: streamInfo.PlayMethod == PlayMethod.DirectPlay;
|
||||
|
||||
mediaSource.SupportsTranscoding =
|
||||
streamInfo.PlayMethod == PlayMethod.DirectStream
|
||||
|| mediaSource.TranscodingContainer is not null
|
||||
|| profile.TranscodingProfiles.Any(i => i.Type == streamInfo.MediaType && i.Context == options.Context);
|
||||
|
||||
if (item is Audio)
|
||||
{
|
||||
if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
|
||||
{
|
||||
mediaSource.SupportsTranscoding = false;
|
||||
}
|
||||
}
|
||||
else if (item is Video)
|
||||
{
|
||||
if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
|
||||
&& !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
|
||||
&& !user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
|
||||
{
|
||||
mediaSource.SupportsTranscoding = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
|
||||
{
|
||||
mediaSource.SupportsDirectPlay = false;
|
||||
mediaSource.SupportsDirectStream = false;
|
||||
|
||||
mediaSource.TranscodingUrl = streamInfo.ToUrl(null, claimsPrincipal.GetToken(), "&allowVideoStreamCopy=false&allowAudioStreamCopy=false");
|
||||
mediaSource.TranscodingContainer = streamInfo.Container;
|
||||
mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
|
||||
if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding)
|
||||
{
|
||||
mediaSource.TranscodingUrl += "&alwaysBurnInSubtitleWhenTranscoding=true";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStream))
|
||||
{
|
||||
streamInfo.PlayMethod = PlayMethod.Transcode;
|
||||
mediaSource.TranscodingUrl = streamInfo.ToUrl(null, claimsPrincipal.GetToken(), null);
|
||||
|
||||
if (!allowVideoStreamCopy)
|
||||
{
|
||||
mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
|
||||
}
|
||||
|
||||
if (!allowAudioStreamCopy)
|
||||
{
|
||||
mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
|
||||
}
|
||||
|
||||
if (streamInfo.AlwaysBurnInSubtitleWhenTranscoding)
|
||||
{
|
||||
mediaSource.TranscodingUrl += "&alwaysBurnInSubtitleWhenTranscoding=true";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do this after the above so that StartPositionTicks is set
|
||||
// The token must not be null
|
||||
SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, claimsPrincipal.GetToken()!);
|
||||
mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex;
|
||||
}
|
||||
|
||||
foreach (var attachment in mediaSource.MediaAttachments)
|
||||
{
|
||||
attachment.DeliveryUrl = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"/Videos/{0}/{1}/Attachments/{2}",
|
||||
item.Id,
|
||||
mediaSource.Id,
|
||||
attachment.Index);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort media source.
|
||||
/// </summary>
|
||||
/// <param name="result">Playback info response.</param>
|
||||
/// <param name="maxBitrate">Max bitrate.</param>
|
||||
public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
|
||||
{
|
||||
var originalList = result.MediaSources.ToList();
|
||||
|
||||
result.MediaSources = result.MediaSources.OrderBy(i =>
|
||||
{
|
||||
// Nothing beats direct playing a file
|
||||
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
})
|
||||
.ThenBy(i =>
|
||||
{
|
||||
// Let's assume direct streaming a file is just as desirable as direct playing a remote url
|
||||
if (i.SupportsDirectPlay || i.SupportsDirectStream)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
})
|
||||
.ThenBy(i =>
|
||||
{
|
||||
return i.Protocol switch
|
||||
{
|
||||
MediaProtocol.File => 0,
|
||||
_ => 1,
|
||||
};
|
||||
})
|
||||
.ThenBy(i =>
|
||||
{
|
||||
if (maxBitrate.HasValue && i.Bitrate.HasValue)
|
||||
{
|
||||
return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2;
|
||||
}
|
||||
|
||||
return 1;
|
||||
})
|
||||
.ThenBy(originalList.IndexOf)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open media source.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">Http Context.</param>
|
||||
/// <param name="request">Live stream request.</param>
|
||||
/// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns>
|
||||
public async Task<LiveStreamResponse> OpenMediaSource(HttpContext httpContext, LiveStreamRequest request)
|
||||
{
|
||||
var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
var profile = request.DeviceProfile;
|
||||
if (profile is null)
|
||||
{
|
||||
var clientCapabilities = _deviceManager.GetCapabilities(httpContext.User.GetDeviceId());
|
||||
if (clientCapabilities is not null)
|
||||
{
|
||||
profile = clientCapabilities.DeviceProfile;
|
||||
}
|
||||
}
|
||||
|
||||
if (profile is not null)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(request.ItemId)
|
||||
?? throw new ResourceNotFoundException();
|
||||
|
||||
SetDeviceSpecificData(
|
||||
item,
|
||||
result.MediaSource,
|
||||
profile,
|
||||
httpContext.User,
|
||||
request.MaxStreamingBitrate,
|
||||
request.StartTimeTicks ?? 0,
|
||||
result.MediaSource.Id,
|
||||
request.AudioStreamIndex,
|
||||
request.SubtitleStreamIndex,
|
||||
request.MaxAudioChannels,
|
||||
request.PlaySessionId,
|
||||
request.UserId,
|
||||
request.EnableDirectPlay,
|
||||
request.EnableDirectStream,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
request.AlwaysBurnInSubtitleWhenTranscoding,
|
||||
httpContext.GetNormalizedRemoteIP());
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl))
|
||||
{
|
||||
result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId;
|
||||
}
|
||||
}
|
||||
|
||||
// here was a check if (result.MediaSource is not null) but Rider said it will never be null
|
||||
NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalize media source container.
|
||||
/// </summary>
|
||||
/// <param name="mediaSource">Media source.</param>
|
||||
/// <param name="profile">Device profile.</param>
|
||||
/// <param name="type">Dlna profile type.</param>
|
||||
public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type)
|
||||
{
|
||||
mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profile, type);
|
||||
}
|
||||
|
||||
private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken)
|
||||
{
|
||||
var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken);
|
||||
mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex;
|
||||
|
||||
mediaSource.TranscodeReasons = info.TranscodeReasons;
|
||||
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
foreach (var stream in mediaSource.MediaStreams)
|
||||
{
|
||||
if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index)
|
||||
{
|
||||
stream.DeliveryMethod = profile.DeliveryMethod;
|
||||
|
||||
if (profile.DeliveryMethod == SubtitleDeliveryMethod.External)
|
||||
{
|
||||
stream.DeliveryUrl = profile.Url.TrimStart('-');
|
||||
stream.IsExternalUrl = profile.IsExternalUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress)
|
||||
{
|
||||
var maxBitrate = clientMaxBitrate;
|
||||
var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0;
|
||||
|
||||
if (remoteClientMaxBitrate <= 0)
|
||||
{
|
||||
remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit;
|
||||
}
|
||||
|
||||
if (remoteClientMaxBitrate > 0)
|
||||
{
|
||||
var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress);
|
||||
|
||||
_logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIP: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork);
|
||||
if (!isInLocalNetwork)
|
||||
{
|
||||
maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate);
|
||||
}
|
||||
}
|
||||
|
||||
return maxBitrate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Api.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Request Extensions.
|
||||
/// </summary>
|
||||
public static class RequestHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Get Order By.
|
||||
/// </summary>
|
||||
/// <param name="sortBy">Sort By. Comma delimited string.</param>
|
||||
/// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param>
|
||||
/// <returns>Order By.</returns>
|
||||
public static (ItemSortBy, SortOrder)[] GetOrderBy(IReadOnlyList<ItemSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
|
||||
{
|
||||
if (sortBy.Count == 0)
|
||||
{
|
||||
return Array.Empty<(ItemSortBy, SortOrder)>();
|
||||
}
|
||||
|
||||
var result = new (ItemSortBy, SortOrder)[sortBy.Count];
|
||||
var i = 0;
|
||||
// Add elements which have a SortOrder specified
|
||||
for (; i < requestedSortOrder.Count; i++)
|
||||
{
|
||||
result[i] = (sortBy[i], requestedSortOrder[i]);
|
||||
}
|
||||
|
||||
// Add remaining elements with the first specified SortOrder
|
||||
// or the default one if no SortOrders are specified
|
||||
var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending;
|
||||
for (; i < sortBy.Count; i++)
|
||||
{
|
||||
result[i] = (sortBy[i], order);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the user can access a user.
|
||||
/// </summary>
|
||||
/// <param name="claimsPrincipal">The <see cref="ClaimsPrincipal"/> for the current request.</param>
|
||||
/// <param name="userId">The user id.</param>
|
||||
/// <returns>A <see cref="bool"/> whether the user can access the user.</returns>
|
||||
internal static Guid GetUserId(ClaimsPrincipal claimsPrincipal, Guid? userId)
|
||||
{
|
||||
var authenticatedUserId = claimsPrincipal.GetUserId();
|
||||
|
||||
// UserId not provided, fall back to authenticated user id.
|
||||
if (userId.IsNullOrEmpty())
|
||||
{
|
||||
return authenticatedUserId;
|
||||
}
|
||||
|
||||
// User must be administrator to access another user.
|
||||
var isAdministrator = claimsPrincipal.IsInRole(UserRoles.Administrator);
|
||||
if (!userId.Value.Equals(authenticatedUserId) && !isAdministrator)
|
||||
{
|
||||
throw new SecurityException("Forbidden");
|
||||
}
|
||||
|
||||
return userId.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the user can update an entry.
|
||||
/// </summary>
|
||||
/// <param name="claimsPrincipal">The <see cref="ClaimsPrincipal"/> for the current request.</param>
|
||||
/// <param name="user">The user id.</param>
|
||||
/// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param>
|
||||
/// <returns>A <see cref="bool"/> whether the user can update the entry.</returns>
|
||||
internal static bool AssertCanUpdateUser(ClaimsPrincipal claimsPrincipal, User user, bool restrictUserPreferences)
|
||||
{
|
||||
var authenticatedUserId = claimsPrincipal.GetUserId();
|
||||
var isAdministrator = claimsPrincipal.IsInRole(UserRoles.Administrator);
|
||||
|
||||
// If they're going to update the record of another user, they must be an administrator
|
||||
if (!user.Id.Equals(authenticatedUserId) && !isAdministrator)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO the EnableUserPreferenceAccess policy does not seem to be used elsewhere
|
||||
if (!restrictUserPreferences || isAdministrator)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return user.EnableUserPreferenceAccess;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the session based on http request.
|
||||
/// </summary>
|
||||
/// <param name="sessionManager">The session manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="httpContext">The http context.</param>
|
||||
/// <param name="userId">The optional userid.</param>
|
||||
/// <returns>The session.</returns>
|
||||
/// <exception cref="ResourceNotFoundException">Session not found.</exception>
|
||||
public static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null)
|
||||
{
|
||||
userId ??= httpContext.User.GetUserId();
|
||||
User? user = null;
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
user = userManager.GetUserById(userId.Value);
|
||||
}
|
||||
|
||||
var session = await sessionManager.LogSessionActivity(
|
||||
httpContext.User.GetClient(),
|
||||
httpContext.User.GetVersion(),
|
||||
httpContext.User.GetDeviceId(),
|
||||
httpContext.User.GetDevice(),
|
||||
httpContext.GetNormalizedRemoteIP().ToString(),
|
||||
user).ConfigureAwait(false);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
throw new ResourceNotFoundException("Session not found.");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
internal static async Task<string> GetSessionId(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext)
|
||||
{
|
||||
var session = await GetSession(sessionManager, userManager, httpContext).ConfigureAwait(false);
|
||||
|
||||
return session.Id;
|
||||
}
|
||||
|
||||
internal static QueryResult<BaseItemDto> CreateQueryResult(
|
||||
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result,
|
||||
DtoOptions dtoOptions,
|
||||
IDtoService dtoService,
|
||||
bool includeItemTypes,
|
||||
User? user)
|
||||
{
|
||||
var dtos = result.Items.Select(i =>
|
||||
{
|
||||
var (baseItem, counts) = i;
|
||||
var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
|
||||
|
||||
if (includeItemTypes)
|
||||
{
|
||||
dto.ChildCount = counts.ItemCount;
|
||||
dto.ProgramCount = counts.ProgramCount;
|
||||
dto.SeriesCount = counts.SeriesCount;
|
||||
dto.EpisodeCount = counts.EpisodeCount;
|
||||
dto.MovieCount = counts.MovieCount;
|
||||
dto.TrailerCount = counts.TrailerCount;
|
||||
dto.AlbumCount = counts.AlbumCount;
|
||||
dto.SongCount = counts.SongCount;
|
||||
dto.ArtistCount = counts.ArtistCount;
|
||||
}
|
||||
|
||||
return dto;
|
||||
});
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
result.StartIndex,
|
||||
result.TotalRecordCount,
|
||||
dtos.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
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.Extensions;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Api.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The streaming helpers.
|
||||
/// </summary>
|
||||
public static class StreamingHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current streaming state.
|
||||
/// </summary>
|
||||
/// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param>
|
||||
/// <param name="httpContext">The <see cref="HttpContext"/>.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
|
||||
/// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
|
||||
/// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns>
|
||||
public static async Task<StreamState> GetStreamingState(
|
||||
StreamingRequestDto streamingRequest,
|
||||
HttpContext httpContext,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
IServerConfigurationManager serverConfigurationManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
EncodingHelper encodingHelper,
|
||||
ITranscodeManager transcodeManager,
|
||||
TranscodingJobType transcodingJobType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var httpRequest = httpContext.Request;
|
||||
if (!string.IsNullOrWhiteSpace(streamingRequest.Params))
|
||||
{
|
||||
ParseParams(streamingRequest);
|
||||
}
|
||||
|
||||
streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query);
|
||||
if (httpRequest.Path.Value is null)
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(httpRequest.Path));
|
||||
}
|
||||
|
||||
var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(streamingRequest.AudioCodec))
|
||||
{
|
||||
streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url);
|
||||
}
|
||||
|
||||
var state = new StreamState(mediaSourceManager, transcodingJobType, transcodeManager)
|
||||
{
|
||||
Request = streamingRequest,
|
||||
RequestedUrl = url,
|
||||
UserAgent = httpRequest.Headers[HeaderNames.UserAgent]
|
||||
};
|
||||
|
||||
var userId = httpContext.User.GetUserId();
|
||||
if (!userId.IsEmpty())
|
||||
{
|
||||
state.User = userManager.GetUserById(userId);
|
||||
}
|
||||
|
||||
if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec))
|
||||
{
|
||||
state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec))
|
||||
{
|
||||
state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec)
|
||||
?? state.SupportedAudioCodecs.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec))
|
||||
{
|
||||
state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(mediaEncoder.CanEncodeToSubtitleCodec)
|
||||
?? state.SupportedSubtitleCodecs.FirstOrDefault();
|
||||
}
|
||||
|
||||
var item = libraryManager.GetItemById<BaseItem>(streamingRequest.Id)
|
||||
?? throw new ResourceNotFoundException();
|
||||
|
||||
state.IsInputVideo = item.MediaType == MediaType.Video;
|
||||
|
||||
MediaSourceInfo? mediaSource = null;
|
||||
if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId))
|
||||
{
|
||||
var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId)
|
||||
? transcodeManager.GetTranscodingJob(streamingRequest.PlaySessionId)
|
||||
: null;
|
||||
|
||||
if (currentJob is not null)
|
||||
{
|
||||
mediaSource = currentJob.MediaSource;
|
||||
}
|
||||
|
||||
if (mediaSource is null)
|
||||
{
|
||||
var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById<BaseItem>(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId)
|
||||
? mediaSources[0]
|
||||
: mediaSources.FirstOrDefault(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal));
|
||||
|
||||
if (mediaSource is null && Guid.Parse(streamingRequest.MediaSourceId).Equals(streamingRequest.Id))
|
||||
{
|
||||
mediaSource = mediaSources[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);
|
||||
mediaSource = liveStreamInfo.Item1;
|
||||
state.DirectStreamProvider = liveStreamInfo.Item2;
|
||||
|
||||
// Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate.
|
||||
if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null)
|
||||
{
|
||||
streamingRequest.VideoBitRate = Math.Min(streamingRequest.VideoBitRate.Value, mediaSource.FallbackMaxStreamingBitrate.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var encodingOptions = serverConfigurationManager.GetEncodingOptions();
|
||||
|
||||
encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url);
|
||||
|
||||
string? containerInternal = Path.GetExtension(state.RequestedUrl);
|
||||
|
||||
if (string.IsNullOrEmpty(containerInternal)
|
||||
&& (!string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)
|
||||
|| (mediaSource != null && mediaSource.IsInfiniteStream)))
|
||||
{
|
||||
containerInternal = ".ts";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(streamingRequest.Container))
|
||||
{
|
||||
containerInternal = streamingRequest.Container;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(containerInternal))
|
||||
{
|
||||
containerInternal = streamingRequest.Static ?
|
||||
StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, null, DlnaProfileType.Audio)
|
||||
: GetOutputFileExtension(state, mediaSource);
|
||||
}
|
||||
|
||||
var outputAudioCodec = streamingRequest.AudioCodec;
|
||||
state.OutputAudioCodec = outputAudioCodec;
|
||||
state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
|
||||
state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec);
|
||||
if (EncodingHelper.LosslessAudioCodecs.Contains(outputAudioCodec))
|
||||
{
|
||||
state.OutputAudioBitrate = state.AudioStream.BitRate ?? 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0;
|
||||
}
|
||||
|
||||
if (outputAudioCodec.StartsWith("pcm_", StringComparison.Ordinal))
|
||||
{
|
||||
containerInternal = ".pcm";
|
||||
}
|
||||
|
||||
if (state.VideoRequest is not null)
|
||||
{
|
||||
state.OutputVideoCodec = state.Request.VideoCodec;
|
||||
state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
|
||||
|
||||
encodingHelper.TryStreamCopy(state, encodingOptions);
|
||||
|
||||
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue)
|
||||
{
|
||||
var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue
|
||||
&& !state.VideoRequest.Height.HasValue
|
||||
&& !state.VideoRequest.MaxWidth.HasValue
|
||||
&& !state.VideoRequest.MaxHeight.HasValue;
|
||||
|
||||
if (isVideoResolutionNotRequested
|
||||
&& state.VideoStream is not null
|
||||
&& state.VideoRequest.VideoBitRate.HasValue
|
||||
&& state.VideoStream.BitRate.HasValue
|
||||
&& state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value)
|
||||
{
|
||||
// Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested,
|
||||
// and the requested video bitrate is greater than source video bitrate.
|
||||
if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue)
|
||||
{
|
||||
state.VideoRequest.MaxWidth = state.VideoStream?.Width;
|
||||
state.VideoRequest.MaxHeight = state.VideoStream?.Height;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var h264EquivalentBitrate = EncodingHelper.ScaleBitrate(
|
||||
state.OutputVideoBitrate.Value,
|
||||
state.ActualOutputVideoCodec,
|
||||
"h264");
|
||||
var resolution = ResolutionNormalizer.Normalize(
|
||||
state.VideoStream?.BitRate,
|
||||
state.OutputVideoBitrate.Value,
|
||||
h264EquivalentBitrate,
|
||||
state.VideoRequest.MaxWidth,
|
||||
state.VideoRequest.MaxHeight,
|
||||
state.TargetFramerate);
|
||||
|
||||
state.VideoRequest.MaxWidth = resolution.MaxWidth;
|
||||
state.VideoRequest.MaxHeight = resolution.MaxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.AudioStream is not null && !EncodingHelper.IsCopyCodec(state.OutputAudioCodec) && string.Equals(state.AudioStream.Codec, state.OutputAudioCodec, StringComparison.OrdinalIgnoreCase) && state.OutputAudioBitrate.HasValue)
|
||||
{
|
||||
state.OutputAudioCodec = state.SupportedAudioCodecs.Where(c => !EncodingHelper.LosslessAudioCodecs.Contains(c)).FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec);
|
||||
}
|
||||
}
|
||||
|
||||
var ext = string.IsNullOrWhiteSpace(state.OutputContainer)
|
||||
? GetOutputFileExtension(state, mediaSource)
|
||||
: ("." + GetContainerFileExtension(state.OutputContainer));
|
||||
|
||||
state.OutputFilePath = GetOutputFilePath(state, ext, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses query parameters as StreamOptions.
|
||||
/// </summary>
|
||||
/// <param name="queryString">The query string.</param>
|
||||
/// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns>
|
||||
private static Dictionary<string, string?> ParseStreamOptions(IQueryCollection queryString)
|
||||
{
|
||||
Dictionary<string, string?> streamOptions = new Dictionary<string, string?>();
|
||||
foreach (var param in queryString)
|
||||
{
|
||||
if (char.IsLower(param.Key[0]))
|
||||
{
|
||||
// This was probably not parsed initially and should be a StreamOptions
|
||||
// or the generated URL should correctly serialize it
|
||||
// TODO: This should be incorporated either in the lower framework for parsing requests
|
||||
streamOptions[param.Key] = param.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return streamOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the output file extension.
|
||||
/// </summary>
|
||||
/// <param name="state">The state.</param>
|
||||
/// <param name="mediaSource">The mediaSource.</param>
|
||||
/// <returns>System.String.</returns>
|
||||
private static string GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource)
|
||||
{
|
||||
var ext = Path.GetExtension(state.RequestedUrl);
|
||||
if (!string.IsNullOrEmpty(ext))
|
||||
{
|
||||
return ext;
|
||||
}
|
||||
|
||||
// Try to infer based on the desired video codec
|
||||
if (state.IsVideoRequest)
|
||||
{
|
||||
var videoCodec = state.Request.VideoCodec;
|
||||
|
||||
if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".ts";
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".mp4";
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".ogv";
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "vp8", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".webm";
|
||||
}
|
||||
|
||||
if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".asf";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try to infer based on the desired audio codec
|
||||
var audioCodec = state.Request.AudioCodec;
|
||||
|
||||
if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".aac";
|
||||
}
|
||||
|
||||
if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".mp3";
|
||||
}
|
||||
|
||||
if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".ogg";
|
||||
}
|
||||
|
||||
if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ".wma";
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the container of mediaSource
|
||||
if (!string.IsNullOrEmpty(mediaSource?.Container))
|
||||
{
|
||||
var idx = mediaSource.Container.IndexOf(',', StringComparison.OrdinalIgnoreCase);
|
||||
return '.' + (idx == -1 ? mediaSource.Container : mediaSource.Container[..idx]).Trim();
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Failed to find an appropriate file extension");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the output file path for transcoding.
|
||||
/// </summary>
|
||||
/// <param name="state">The current <see cref="StreamState"/>.</param>
|
||||
/// <param name="outputFileExtension">The file extension of the output file.</param>
|
||||
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <param name="playSessionId">The play session id.</param>
|
||||
/// <returns>The complete file path, including the folder, for the transcoding file.</returns>
|
||||
private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId)
|
||||
{
|
||||
var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}";
|
||||
|
||||
var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
||||
var ext = outputFileExtension.ToLowerInvariant();
|
||||
var folder = serverConfigurationManager.GetTranscodePath();
|
||||
|
||||
return Path.Combine(folder, filename + ext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the parameters.
|
||||
/// </summary>
|
||||
/// <param name="request">The request.</param>
|
||||
private static void ParseParams(StreamingRequestDto request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Params))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var vals = request.Params.Split(';');
|
||||
|
||||
var videoRequest = request as VideoRequestDto;
|
||||
|
||||
for (var i = 0; i < vals.Length; i++)
|
||||
{
|
||||
var val = vals[i];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(val))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (i)
|
||||
{
|
||||
case 0:
|
||||
// DeviceProfileId
|
||||
break;
|
||||
case 1:
|
||||
request.DeviceId = val;
|
||||
break;
|
||||
case 2:
|
||||
request.MediaSourceId = val;
|
||||
break;
|
||||
case 3:
|
||||
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
break;
|
||||
case 4:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.VideoCodec = val;
|
||||
}
|
||||
|
||||
break;
|
||||
case 5:
|
||||
request.AudioCodec = val;
|
||||
break;
|
||||
case 6:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 7:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 8:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 9:
|
||||
request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case 10:
|
||||
request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case 11:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 12:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 13:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 14:
|
||||
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case 15:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.Level = val;
|
||||
}
|
||||
|
||||
break;
|
||||
case 16:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 17:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
break;
|
||||
case 18:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.Profile = val;
|
||||
}
|
||||
|
||||
break;
|
||||
case 19:
|
||||
// cabac no longer used
|
||||
break;
|
||||
case 20:
|
||||
request.PlaySessionId = val;
|
||||
break;
|
||||
case 21:
|
||||
// api_key
|
||||
break;
|
||||
case 22:
|
||||
request.LiveStreamId = val;
|
||||
break;
|
||||
case 23:
|
||||
// Duplicating ItemId because of MediaMonkey
|
||||
break;
|
||||
case 24:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
break;
|
||||
case 25:
|
||||
if (!string.IsNullOrWhiteSpace(val) && videoRequest is not null)
|
||||
{
|
||||
if (Enum.TryParse(val, out SubtitleDeliveryMethod method))
|
||||
{
|
||||
videoRequest.SubtitleMethod = method;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case 26:
|
||||
request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case 27:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
break;
|
||||
case 28:
|
||||
request.Tag = val;
|
||||
break;
|
||||
case 29:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
break;
|
||||
case 30:
|
||||
request.SubtitleCodec = val;
|
||||
break;
|
||||
case 31:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
break;
|
||||
case 32:
|
||||
if (videoRequest is not null)
|
||||
{
|
||||
videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
break;
|
||||
case 33:
|
||||
request.TranscodeReasons = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the container into its file extension.
|
||||
/// </summary>
|
||||
/// <param name="container">The container.</param>
|
||||
private static string? GetContainerFileExtension(string? container)
|
||||
{
|
||||
if (string.Equals(container, "mpegts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "ts";
|
||||
}
|
||||
|
||||
if (string.Equals(container, "matroska", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "mkv";
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
|
||||
<PropertyGroup>
|
||||
<ProjectGuid>{DFBEFB4C-DA19-4143-98B7-27320C7F7163}</ProjectGuid>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.ReDoc" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
|
||||
<ProjectReference Include="..\MediaBrowser.MediaEncoding\MediaBrowser.MediaEncoding.csproj" />
|
||||
<ProjectReference Include="..\src\Jellyfin.MediaEncoding.Hls\Jellyfin.MediaEncoding.Hls.csproj" />
|
||||
<ProjectReference Include="..\src\Jellyfin.Networking\Jellyfin.Networking.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Code Analyzers -->
|
||||
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<PackageReference Include="IDisposableAnalyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
|
||||
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
|
||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>Jellyfin.Api.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user