repo creation with initial code after cloning public repo

This commit is contained in:
2026-02-19 07:36:25 -05:00
commit 460884fea3
2860 changed files with 650825 additions and 0 deletions
@@ -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; }
}
}
@@ -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;
}
}
}
@@ -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; }
}
}
+37
View File
@@ -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";
}
+22
View File
@@ -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);
}
}
+361
View File
@@ -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;
}
}
+636
View File
@@ -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);
}
}
+227
View File
@@ -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;
}
+51
View File
@@ -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);
}
}
+157
View File
@@ -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);
}
}
+920
View File
@@ -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();
}
}
+132
View File
@@ -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;
}
}
+518
View File
@@ -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;
}
}
+188
View File
@@ -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());
}
}
+607
View File
@@ -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;
}
}
+48
View File
@@ -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