From 890e659cd390fc45c68b42c1a20f24a33e8c1570 Mon Sep 17 00:00:00 2001 From: crobibero Date: Sat, 25 Apr 2020 15:12:18 -0600 Subject: Fix autolaunch & redirect of swagger. --- Emby.Server.Implementations/Browser/BrowserLauncher.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Browser/BrowserLauncher.cs b/Emby.Server.Implementations/Browser/BrowserLauncher.cs index 96096e142..384cb049f 100644 --- a/Emby.Server.Implementations/Browser/BrowserLauncher.cs +++ b/Emby.Server.Implementations/Browser/BrowserLauncher.cs @@ -1,5 +1,7 @@ using System; using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Browser @@ -24,7 +26,7 @@ namespace Emby.Server.Implementations.Browser /// The app host. public static void OpenSwaggerPage(IServerApplicationHost appHost) { - TryOpenUrl(appHost, "/swagger/index.html"); + TryOpenUrl(appHost, "/api-docs/v1/swagger"); } /// -- cgit v1.2.3 From 806ae1bc07e715c6109a3e8ec96c6d3dd6a802ef Mon Sep 17 00:00:00 2001 From: crobibero Date: Wed, 29 Apr 2020 08:04:05 -0600 Subject: Remove versioned API --- Emby.Server.Implementations/Browser/BrowserLauncher.cs | 2 +- .../Extensions/ApiApplicationBuilderExtensions.cs | 16 ++++++++-------- .../Extensions/ApiServiceCollectionExtensions.cs | 2 +- Jellyfin.Server/Program.cs | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Browser/BrowserLauncher.cs b/Emby.Server.Implementations/Browser/BrowserLauncher.cs index 384cb049f..e706401fd 100644 --- a/Emby.Server.Implementations/Browser/BrowserLauncher.cs +++ b/Emby.Server.Implementations/Browser/BrowserLauncher.cs @@ -26,7 +26,7 @@ namespace Emby.Server.Implementations.Browser /// The app host. public static void OpenSwaggerPage(IServerApplicationHost appHost) { - TryOpenUrl(appHost, "/api-docs/v1/swagger"); + TryOpenUrl(appHost, "/api-docs/swagger"); } /// diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index 33fd77d9c..745567703 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -1,5 +1,4 @@ using MediaBrowser.Controller.Configuration; -using Jellyfin.Server.Middleware; using Microsoft.AspNetCore.Builder; namespace Jellyfin.Server.Extensions @@ -31,19 +30,20 @@ namespace Jellyfin.Server.Extensions return applicationBuilder .UseSwagger(c => { - c.RouteTemplate = $"/{baseUrl}api-docs/{{documentName}}/openapi.json"; + // Custom path requires {documentName}, SwaggerDoc documentName is 'api-docs' + c.RouteTemplate = $"/{baseUrl}{{documentName}}/openapi.json"; }) .UseSwaggerUI(c => { - c.DocumentTitle = "Jellyfin API v1"; - c.SwaggerEndpoint($"/{baseUrl}api-docs/v1/openapi.json", "Jellyfin API v1"); - c.RoutePrefix = $"{baseUrl}api-docs/v1/swagger"; + c.DocumentTitle = "Jellyfin API"; + c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API"); + c.RoutePrefix = $"{baseUrl}api-docs/swagger"; }) .UseReDoc(c => { - c.DocumentTitle = "Jellyfin API v1"; - c.SpecUrl($"/{baseUrl}api-docs/v1/openapi.json"); - c.RoutePrefix = $"{baseUrl}api-docs/v1/redoc"; + c.DocumentTitle = "Jellyfin API"; + c.SpecUrl($"/{baseUrl}api-docs/openapi.json"); + c.RoutePrefix = $"{baseUrl}api-docs/redoc"; }); } } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index a24785d57..a354f45aa 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -96,7 +96,7 @@ namespace Jellyfin.Server.Extensions { return serviceCollection.AddSwaggerGen(c => { - c.SwaggerDoc("v1", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); + c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API" }); // Add all xml doc files to swagger generator. var xmlFiles = Directory.GetFiles( diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 23ddcf159..713580080 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -529,7 +529,7 @@ namespace Jellyfin.Server var inMemoryDefaultConfig = ConfigurationOptions.DefaultConfiguration; if (startupConfig != null && !startupConfig.HostWebClient()) { - inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "api-docs/v1/swagger"; + inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "api-docs/swagger"; } return config -- cgit v1.2.3 From e30a85025f3d0f8b936827613239da7c2c2387c2 Mon Sep 17 00:00:00 2001 From: crobibero Date: Mon, 1 Jun 2020 12:42:59 -0600 Subject: Remove log spam when using legacy api --- .../HttpServer/Security/AuthService.cs | 6 ++++++ Jellyfin.Api/Auth/CustomAuthenticationHandler.cs | 11 +++++++++-- MediaBrowser.Controller/Net/AuthenticatedAttribute.cs | 4 ++++ 3 files changed, 19 insertions(+), 2 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index 58421aaf1..18bea59ad 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -146,11 +146,17 @@ namespace Emby.Server.Implementations.HttpServer.Security { return true; } + if (authAttribtues.AllowLocalOnly && request.IsLocal) { return true; } + if (authAttribtues.IgnoreLegacyAuth) + { + return true; + } + return false; } diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 26f7d9d2d..a0c9c3f5a 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -37,13 +37,20 @@ namespace Jellyfin.Api.Auth /// protected override Task HandleAuthenticateAsync() { - var authenticatedAttribute = new AuthenticatedAttribute(); + var authenticatedAttribute = new AuthenticatedAttribute + { + IgnoreLegacyAuth = true + }; + try { var user = _authService.Authenticate(Request, authenticatedAttribute); if (user == null) { - return Task.FromResult(AuthenticateResult.Fail("Invalid user")); + return Task.FromResult(AuthenticateResult.NoResult()); + // TODO return when legacy API is removed. + // Don't spam the log with "Invalid User" + // return Task.FromResult(AuthenticateResult.Fail("Invalid user")); } var claims = new[] diff --git a/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs b/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs index 29fb81e32..9f2743ea1 100644 --- a/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs +++ b/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs @@ -52,6 +52,8 @@ namespace MediaBrowser.Controller.Net return (Roles ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); } + public bool IgnoreLegacyAuth { get; set; } + public bool AllowLocalOnly { get; set; } } @@ -63,5 +65,7 @@ namespace MediaBrowser.Controller.Net bool AllowLocalOnly { get; } string[] GetRoles(); + + bool IgnoreLegacyAuth { get; } } } -- cgit v1.2.3 From 4aac93672115d96ab77534f2b6a32a23482dab38 Mon Sep 17 00:00:00 2001 From: crobibero Date: Mon, 15 Jun 2020 12:49:54 -0600 Subject: Add more authorization handlers, actually authorize requests --- .../HttpServer/Security/AuthService.cs | 45 +++++---- Jellyfin.Api/Auth/BaseAuthorizationHandler.cs | 102 +++++++++++++++++++++ Jellyfin.Api/Auth/CustomAuthenticationHandler.cs | 25 ++--- .../DefaultAuthorizationHandler.cs | 42 +++++++++ .../DefaultAuthorizationRequirement.cs | 11 +++ .../FirstTimeSetupOrElevatedHandler.cs | 21 ++++- .../IgnoreSchedulePolicy/IgnoreScheduleHandler.cs | 42 +++++++++ .../IgnoreScheduleRequirement.cs | 11 +++ .../Auth/LocalAccessPolicy/LocalAccessHandler.cs | 44 +++++++++ .../LocalAccessPolicy/LocalAccessRequirement.cs | 11 +++ .../RequiresElevationHandler.cs | 26 +++++- Jellyfin.Api/Constants/InternalClaimTypes.cs | 38 ++++++++ Jellyfin.Api/Constants/Policies.cs | 15 +++ Jellyfin.Api/Helpers/ClaimHelpers.cs | 77 ++++++++++++++++ .../Extensions/ApiServiceCollectionExtensions.cs | 31 ++++++- MediaBrowser.Controller/Net/IAuthService.cs | 25 ++++- 16 files changed, 525 insertions(+), 41 deletions(-) create mode 100644 Jellyfin.Api/Auth/BaseAuthorizationHandler.cs create mode 100644 Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs create mode 100644 Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs create mode 100644 Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs create mode 100644 Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs create mode 100644 Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs create mode 100644 Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs create mode 100644 Jellyfin.Api/Constants/InternalClaimTypes.cs create mode 100644 Jellyfin.Api/Helpers/ClaimHelpers.cs (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index c65d4694a..6a2d8fdbb 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -39,9 +39,9 @@ namespace Emby.Server.Implementations.HttpServer.Security _networkManager = networkManager; } - public void Authenticate(IRequest request, IAuthenticationAttributes authAttribtues) + public void Authenticate(IRequest request, IAuthenticationAttributes authAttributes) { - ValidateUser(request, authAttribtues); + ValidateUser(request, authAttributes); } public User Authenticate(HttpRequest request, IAuthenticationAttributes authAttributes) @@ -51,17 +51,33 @@ namespace Emby.Server.Implementations.HttpServer.Security return user; } - private User ValidateUser(IRequest request, IAuthenticationAttributes authAttribtues) + public AuthorizationInfo Authenticate(HttpRequest request) + { + var auth = _authorizationContext.GetAuthorizationInfo(request); + if (auth?.User == null) + { + return null; + } + + if (auth.User.HasPermission(PermissionKind.IsDisabled)) + { + throw new SecurityException("User account has been disabled."); + } + + return auth; + } + + private User ValidateUser(IRequest request, IAuthenticationAttributes authAttributes) { // This code is executed before the service var auth = _authorizationContext.GetAuthorizationInfo(request); - if (!IsExemptFromAuthenticationToken(authAttribtues, request)) + if (!IsExemptFromAuthenticationToken(authAttributes, request)) { ValidateSecurityToken(request, auth.Token); } - if (authAttribtues.AllowLocalOnly && !request.IsLocal) + if (authAttributes.AllowLocalOnly && !request.IsLocal) { throw new SecurityException("Operation not found."); } @@ -75,14 +91,14 @@ namespace Emby.Server.Implementations.HttpServer.Security if (user != null) { - ValidateUserAccess(user, request, authAttribtues, auth); + ValidateUserAccess(user, request, authAttributes); } var info = GetTokenInfo(request); - if (!IsExemptFromRoles(auth, authAttribtues, request, info)) + if (!IsExemptFromRoles(auth, authAttributes, request, info)) { - var roles = authAttribtues.GetRoles(); + var roles = authAttributes.GetRoles(); ValidateRoles(roles, user); } @@ -106,8 +122,7 @@ namespace Emby.Server.Implementations.HttpServer.Security private void ValidateUserAccess( User user, IRequest request, - IAuthenticationAttributes authAttributes, - AuthorizationInfo auth) + IAuthenticationAttributes authAttributes) { if (user.HasPermission(PermissionKind.IsDisabled)) { @@ -230,16 +245,6 @@ namespace Emby.Server.Implementations.HttpServer.Security { throw new AuthenticationException("Access token is invalid or expired."); } - - //if (!string.IsNullOrEmpty(info.UserId)) - //{ - // var user = _userManager.GetUserById(info.UserId); - - // if (user == null || user.Configuration.IsDisabled) - // { - // throw new SecurityException("User account has been disabled."); - // } - //} } } } diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs new file mode 100644 index 000000000..b5b9d8904 --- /dev/null +++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs @@ -0,0 +1,102 @@ +#nullable enable + +using System.Net; +using System.Security.Claims; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth +{ + /// + /// Base authorization handler. + /// + /// Type of Authorization Requirement. + public abstract class BaseAuthorizationHandler : AuthorizationHandler + where T : IAuthorizationRequirement + { + private readonly IUserManager _userManager; + private readonly INetworkManager _networkManager; + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + protected BaseAuthorizationHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + { + _userManager = userManager; + _networkManager = networkManager; + _httpContextAccessor = httpContextAccessor; + } + + /// + /// Validate authenticated claims. + /// + /// Request claims. + /// Whether to ignore parental control. + /// Whether access is to be allowed locally only. + /// Validated claim status. + protected bool ValidateClaims( + ClaimsPrincipal claimsPrincipal, + bool ignoreSchedule = false, + bool localAccessOnly = false) + { + // Ensure claim has userId. + var userId = ClaimHelpers.GetUserId(claimsPrincipal); + if (userId == null) + { + return false; + } + + // Ensure userId links to a valid user. + var user = _userManager.GetUserById(userId.Value); + if (user == null) + { + return false; + } + + // Ensure user is not disabled. + if (user.HasPermission(PermissionKind.IsDisabled)) + { + return false; + } + + var ip = NormalizeIp(_httpContextAccessor.HttpContext.Connection.RemoteIpAddress).ToString(); + var isInLocalNetwork = _networkManager.IsInLocalNetwork(ip); + // User cannot access remotely and user is remote + if (!user.HasPermission(PermissionKind.EnableRemoteAccess) && !isInLocalNetwork) + { + return false; + } + + if (localAccessOnly && !isInLocalNetwork) + { + return false; + } + + // User attempting to access out of parental control hours. + if (!ignoreSchedule + && !user.HasPermission(PermissionKind.IsAdministrator) + && !user.IsParentalScheduleAllowed()) + { + return false; + } + + return true; + } + + private static IPAddress NormalizeIp(IPAddress ip) + { + return ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4() : ip; + } + } +} diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index a5c4e9974..d4d40da57 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -1,3 +1,6 @@ +#nullable enable + +using System.Globalization; using System.Security.Authentication; using System.Security.Claims; using System.Text.Encodings.Web; @@ -39,15 +42,10 @@ namespace Jellyfin.Api.Auth /// protected override Task HandleAuthenticateAsync() { - var authenticatedAttribute = new AuthenticatedAttribute - { - IgnoreLegacyAuth = true - }; - try { - var user = _authService.Authenticate(Request, authenticatedAttribute); - if (user == null) + var authorizationInfo = _authService.Authenticate(Request); + if (authorizationInfo == null) { return Task.FromResult(AuthenticateResult.NoResult()); // TODO return when legacy API is removed. @@ -57,11 +55,16 @@ namespace Jellyfin.Api.Auth var claims = new[] { - new Claim(ClaimTypes.Name, user.Username), - new Claim( - ClaimTypes.Role, - value: user.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User) + new Claim(ClaimTypes.Name, authorizationInfo.User.Username), + new Claim(ClaimTypes.Role, value: authorizationInfo.User.HasPermission(PermissionKind.IsAdministrator) ? UserRoles.Administrator : UserRoles.User), + new Claim(InternalClaimTypes.UserId, authorizationInfo.UserId.ToString("N", CultureInfo.InvariantCulture)), + new Claim(InternalClaimTypes.DeviceId, authorizationInfo.DeviceId), + new Claim(InternalClaimTypes.Device, authorizationInfo.Device), + new Claim(InternalClaimTypes.Client, authorizationInfo.Client), + new Claim(InternalClaimTypes.Version, authorizationInfo.Version), + new Claim(InternalClaimTypes.Token, authorizationInfo.Token) }; + var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs new file mode 100644 index 000000000..b5913daab --- /dev/null +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy +{ + /// + /// Default authorization handler. + /// + public class DefaultAuthorizationHandler : BaseAuthorizationHandler + { + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public DefaultAuthorizationHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + + /// + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DefaultAuthorizationRequirement requirement) + { + var validated = ValidateClaims(context.User); + if (!validated) + { + context.Fail(); + return Task.CompletedTask; + } + + context.Succeed(requirement); + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs new file mode 100644 index 000000000..7cea00b69 --- /dev/null +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy +{ + /// + /// The default authorization requirement. + /// + public class DefaultAuthorizationRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs index 34aa5d12c..0b12f7d3c 100644 --- a/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs +++ b/Jellyfin.Api/Auth/FirstTimeSetupOrElevatedPolicy/FirstTimeSetupOrElevatedHandler.cs @@ -1,22 +1,33 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy { /// /// Authorization handler for requiring first time setup or elevated privileges. /// - public class FirstTimeSetupOrElevatedHandler : AuthorizationHandler + public class FirstTimeSetupOrElevatedHandler : BaseAuthorizationHandler { private readonly IConfigurationManager _configurationManager; /// /// Initializes a new instance of the class. /// - /// The jellyfin configuration manager. - public FirstTimeSetupOrElevatedHandler(IConfigurationManager configurationManager) + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public FirstTimeSetupOrElevatedHandler( + IConfigurationManager configurationManager, + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) { _configurationManager = configurationManager; } @@ -28,7 +39,9 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy { context.Succeed(firstTimeSetupOrElevatedRequirement); } - else if (context.User.IsInRole(UserRoles.Administrator)) + + var validated = ValidateClaims(context.User); + if (validated && context.User.IsInRole(UserRoles.Administrator)) { context.Succeed(firstTimeSetupOrElevatedRequirement); } diff --git a/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs new file mode 100644 index 000000000..9afa0b28f --- /dev/null +++ b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleHandler.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.IgnoreSchedulePolicy +{ + /// + /// Escape schedule controls handler. + /// + public class IgnoreScheduleHandler : BaseAuthorizationHandler + { + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public IgnoreScheduleHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + + /// + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IgnoreScheduleRequirement requirement) + { + var validated = ValidateClaims(context.User, ignoreSchedule: true); + if (!validated) + { + context.Fail(); + return Task.CompletedTask; + } + + context.Succeed(requirement); + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs new file mode 100644 index 000000000..d5bb61ce6 --- /dev/null +++ b/Jellyfin.Api/Auth/IgnoreSchedulePolicy/IgnoreScheduleRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.IgnoreSchedulePolicy +{ + /// + /// Escape schedule controls requirement. + /// + public class IgnoreScheduleRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs new file mode 100644 index 000000000..af73352bc --- /dev/null +++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessHandler.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.LocalAccessPolicy +{ + /// + /// Local access handler. + /// + public class LocalAccessHandler : BaseAuthorizationHandler + { + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public LocalAccessHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + + /// + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessRequirement requirement) + { + var validated = ValidateClaims(context.User, localAccessOnly: true); + if (!validated) + { + context.Fail(); + } + else + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs new file mode 100644 index 000000000..761127fa4 --- /dev/null +++ b/Jellyfin.Api/Auth/LocalAccessPolicy/LocalAccessRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.LocalAccessPolicy +{ + /// + /// The local access authorization requirement. + /// + public class LocalAccessRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs index 2d3bb1aa4..b235c4b63 100644 --- a/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs +++ b/Jellyfin.Api/Auth/RequiresElevationPolicy/RequiresElevationHandler.cs @@ -1,21 +1,43 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; namespace Jellyfin.Api.Auth.RequiresElevationPolicy { /// /// Authorization handler for requiring elevated privileges. /// - public class RequiresElevationHandler : AuthorizationHandler + public class RequiresElevationHandler : BaseAuthorizationHandler { + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public RequiresElevationHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + /// protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RequiresElevationRequirement requirement) { - if (context.User.IsInRole(UserRoles.Administrator)) + var validated = ValidateClaims(context.User); + if (validated && context.User.IsInRole(UserRoles.Administrator)) { context.Succeed(requirement); } + else + { + context.Fail(); + } return Task.CompletedTask; } diff --git a/Jellyfin.Api/Constants/InternalClaimTypes.cs b/Jellyfin.Api/Constants/InternalClaimTypes.cs new file mode 100644 index 000000000..4d7c7135d --- /dev/null +++ b/Jellyfin.Api/Constants/InternalClaimTypes.cs @@ -0,0 +1,38 @@ +namespace Jellyfin.Api.Constants +{ + /// + /// Internal claim types for authorization. + /// + public static class InternalClaimTypes + { + /// + /// User Id. + /// + public const string UserId = "Jellyfin-UserId"; + + /// + /// Device Id. + /// + public const string DeviceId = "Jellyfin-DeviceId"; + + /// + /// Device. + /// + public const string Device = "Jellyfin-Device"; + + /// + /// Client. + /// + public const string Client = "Jellyfin-Client"; + + /// + /// Version. + /// + public const string Version = "Jellyfin-Version"; + + /// + /// Token. + /// + public const string Token = "Jellyfin-Token"; + } +} diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index e2b383f75..cf574e43d 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -5,6 +5,11 @@ namespace Jellyfin.Api.Constants /// public static class Policies { + /// + /// Policy name for default authorization. + /// + public const string DefaultAuthorization = "DefaultAuthorization"; + /// /// Policy name for requiring first time setup or elevated privileges. /// @@ -14,5 +19,15 @@ namespace Jellyfin.Api.Constants /// Policy name for requiring elevated privileges. /// public const string RequiresElevation = "RequiresElevation"; + + /// + /// Policy name for allowing local access only. + /// + public const string LocalAccessOnly = "LocalAccessOnly"; + + /// + /// Policy name for escaping schedule controls. + /// + public const string IgnoreSchedule = "IgnoreSchedule"; } } diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs new file mode 100644 index 000000000..a07d4ed82 --- /dev/null +++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs @@ -0,0 +1,77 @@ +#nullable enable + +using System; +using System.Linq; +using System.Security.Claims; +using Jellyfin.Api.Constants; + +namespace Jellyfin.Api.Helpers +{ + /// + /// Claim Helpers. + /// + public static class ClaimHelpers + { + /// + /// Get user id from claims. + /// + /// Current claims principal. + /// User id. + public static Guid? GetUserId(in ClaimsPrincipal user) + { + var value = GetClaimValue(user, InternalClaimTypes.UserId); + return string.IsNullOrEmpty(value) + ? null + : (Guid?)Guid.Parse(value); + } + + /// + /// Get device id from claims. + /// + /// Current claims principal. + /// Device id. + public static string? GetDeviceId(in ClaimsPrincipal user) + => GetClaimValue(user, InternalClaimTypes.DeviceId); + + /// + /// Get device from claims. + /// + /// Current claims principal. + /// Device. + public static string? GetDevice(in ClaimsPrincipal user) + => GetClaimValue(user, InternalClaimTypes.Device); + + /// + /// Get client from claims. + /// + /// Current claims principal. + /// Client. + public static string? GetClient(in ClaimsPrincipal user) + => GetClaimValue(user, InternalClaimTypes.Client); + + /// + /// Get version from claims. + /// + /// Current claims principal. + /// Version. + public static string? GetVersion(in ClaimsPrincipal user) + => GetClaimValue(user, InternalClaimTypes.Version); + + /// + /// Get token from claims. + /// + /// Current claims principal. + /// Token. + public static string? GetToken(in ClaimsPrincipal user) + => GetClaimValue(user, InternalClaimTypes.Token); + + private static string? GetClaimValue(in ClaimsPrincipal user, string name) + { + return user?.Identities + .SelectMany(c => c.Claims) + .Where(claim => claim.Type.Equals(name, StringComparison.OrdinalIgnoreCase)) + .Select(claim => claim.Value) + .FirstOrDefault(); + } + } +} diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 9cdaa0eb1..1ec77d716 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -5,7 +5,10 @@ using System.Linq; using System.Reflection; using Jellyfin.Api; using Jellyfin.Api.Auth; +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; +using Jellyfin.Api.Auth.IgnoreSchedulePolicy; +using Jellyfin.Api.Auth.LocalAccessPolicy; using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; @@ -33,16 +36,19 @@ namespace Jellyfin.Server.Extensions /// The updated service collection. public static IServiceCollection AddJellyfinApiAuthorization(this IServiceCollection serviceCollection) { + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); return serviceCollection.AddAuthorizationCore(options => { options.AddPolicy( - Policies.RequiresElevation, + Policies.DefaultAuthorization, policy => { policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); - policy.AddRequirements(new RequiresElevationRequirement()); + policy.AddRequirements(new DefaultAuthorizationRequirement()); }); options.AddPolicy( Policies.FirstTimeSetupOrElevated, @@ -51,6 +57,27 @@ namespace Jellyfin.Server.Extensions policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); policy.AddRequirements(new FirstTimeSetupOrElevatedRequirement()); }); + options.AddPolicy( + Policies.IgnoreSchedule, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new IgnoreScheduleRequirement()); + }); + options.AddPolicy( + Policies.LocalAccessOnly, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new LocalAccessRequirement()); + }); + options.AddPolicy( + Policies.RequiresElevation, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new RequiresElevationRequirement()); + }); }); } diff --git a/MediaBrowser.Controller/Net/IAuthService.cs b/MediaBrowser.Controller/Net/IAuthService.cs index d8f6d19da..2055a656a 100644 --- a/MediaBrowser.Controller/Net/IAuthService.cs +++ b/MediaBrowser.Controller/Net/IAuthService.cs @@ -6,10 +6,31 @@ using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller.Net { + /// + /// IAuthService. + /// public interface IAuthService { - void Authenticate(IRequest request, IAuthenticationAttributes authAttribtues); + /// + /// Authenticate and authorize request. + /// + /// Request. + /// Authorization attributes. + void Authenticate(IRequest request, IAuthenticationAttributes authAttribtutes); - User? Authenticate(HttpRequest request, IAuthenticationAttributes authAttribtues); + /// + /// Authenticate and authorize request. + /// + /// Request. + /// Authorization attributes. + /// Authenticated user. + User? Authenticate(HttpRequest request, IAuthenticationAttributes authAttribtutes); + + /// + /// Authenticate request. + /// + /// The request. + /// Authorization information. Null if unauthenticated. + AuthorizationInfo Authenticate(HttpRequest request); } } -- cgit v1.2.3 From a8adbef74fc8300190c463a9c585b55dcfb0c78e Mon Sep 17 00:00:00 2001 From: crobibero Date: Mon, 15 Jun 2020 13:21:18 -0600 Subject: Add GetAuthorizationInfo for netcore HttpRequest --- .../HttpServer/Security/AuthorizationContext.cs | 132 +++++++++++++++------ .../Net/IAuthorizationContext.cs | 11 ++ 2 files changed, 104 insertions(+), 39 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs index 9558cb4c6..deb9b5ebb 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs @@ -8,6 +8,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; using MediaBrowser.Model.Services; +using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; namespace Emby.Server.Implementations.HttpServer.Security @@ -38,6 +39,14 @@ namespace Emby.Server.Implementations.HttpServer.Security return GetAuthorization(requestContext); } + public AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext) + { + var auth = GetAuthorizationDictionary(requestContext); + var (authInfo, _) = + GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query); + return authInfo; + } + /// /// Gets the authorization. /// @@ -46,7 +55,23 @@ namespace Emby.Server.Implementations.HttpServer.Security private AuthorizationInfo GetAuthorization(IRequest httpReq) { var auth = GetAuthorizationDictionary(httpReq); + var (authInfo, originalAuthInfo) = + GetAuthorizationInfoFromDictionary(auth, httpReq.Headers, httpReq.QueryString); + + if (originalAuthInfo != null) + { + httpReq.Items["OriginalAuthenticationInfo"] = originalAuthInfo; + } + httpReq.Items["AuthorizationInfo"] = authInfo; + return authInfo; + } + + private (AuthorizationInfo authInfo, AuthenticationInfo originalAuthenticationInfo) GetAuthorizationInfoFromDictionary( + in Dictionary auth, + in IHeaderDictionary headers, + in IQueryCollection queryString) + { string deviceId = null; string device = null; string client = null; @@ -64,19 +89,31 @@ namespace Emby.Server.Implementations.HttpServer.Security if (string.IsNullOrEmpty(token)) { - token = httpReq.Headers["X-Emby-Token"]; + token = headers["X-Jellyfin-Token"]; + } + + if (string.IsNullOrEmpty(token)) + { + token = headers["X-Emby-Token"]; } if (string.IsNullOrEmpty(token)) { - token = httpReq.Headers["X-MediaBrowser-Token"]; + token = headers["X-MediaBrowser-Token"]; } + if (string.IsNullOrEmpty(token)) { - token = httpReq.QueryString["api_key"]; + token = queryString["ApiKey"]; } - var info = new AuthorizationInfo + // TODO depricate this query parameter. + if (string.IsNullOrEmpty(token)) + { + token = queryString["api_key"]; + } + + var authInfo = new AuthorizationInfo { Client = client, Device = device, @@ -85,6 +122,7 @@ namespace Emby.Server.Implementations.HttpServer.Security Token = token }; + AuthenticationInfo originalAuthenticationInfo = null; if (!string.IsNullOrWhiteSpace(token)) { var result = _authRepo.Get(new AuthenticationInfoQuery @@ -92,81 +130,77 @@ namespace Emby.Server.Implementations.HttpServer.Security AccessToken = token }); - var tokenInfo = result.Items.Count > 0 ? result.Items[0] : null; + originalAuthenticationInfo = result.Items.Count > 0 ? result.Items[0] : null; - if (tokenInfo != null) + if (originalAuthenticationInfo != null) { var updateToken = false; // TODO: Remove these checks for IsNullOrWhiteSpace - if (string.IsNullOrWhiteSpace(info.Client)) + if (string.IsNullOrWhiteSpace(authInfo.Client)) { - info.Client = tokenInfo.AppName; + authInfo.Client = originalAuthenticationInfo.AppName; } - if (string.IsNullOrWhiteSpace(info.DeviceId)) + if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) { - info.DeviceId = tokenInfo.DeviceId; + authInfo.DeviceId = originalAuthenticationInfo.DeviceId; } // Temporary. TODO - allow clients to specify that the token has been shared with a casting device - var allowTokenInfoUpdate = info.Client == null || info.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1; + var allowTokenInfoUpdate = authInfo.Client == null || authInfo.Client.IndexOf("chromecast", StringComparison.OrdinalIgnoreCase) == -1; - if (string.IsNullOrWhiteSpace(info.Device)) + if (string.IsNullOrWhiteSpace(authInfo.Device)) { - info.Device = tokenInfo.DeviceName; + authInfo.Device = originalAuthenticationInfo.DeviceName; } - - else if (!string.Equals(info.Device, tokenInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) + else if (!string.Equals(authInfo.Device, originalAuthenticationInfo.DeviceName, StringComparison.OrdinalIgnoreCase)) { if (allowTokenInfoUpdate) { updateToken = true; - tokenInfo.DeviceName = info.Device; + originalAuthenticationInfo.DeviceName = authInfo.Device; } } - if (string.IsNullOrWhiteSpace(info.Version)) + if (string.IsNullOrWhiteSpace(authInfo.Version)) { - info.Version = tokenInfo.AppVersion; + authInfo.Version = originalAuthenticationInfo.AppVersion; } - else if (!string.Equals(info.Version, tokenInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) + else if (!string.Equals(authInfo.Version, originalAuthenticationInfo.AppVersion, StringComparison.OrdinalIgnoreCase)) { if (allowTokenInfoUpdate) { updateToken = true; - tokenInfo.AppVersion = info.Version; + originalAuthenticationInfo.AppVersion = authInfo.Version; } } - if ((DateTime.UtcNow - tokenInfo.DateLastActivity).TotalMinutes > 3) + if ((DateTime.UtcNow - originalAuthenticationInfo.DateLastActivity).TotalMinutes > 3) { - tokenInfo.DateLastActivity = DateTime.UtcNow; + originalAuthenticationInfo.DateLastActivity = DateTime.UtcNow; updateToken = true; } - if (!tokenInfo.UserId.Equals(Guid.Empty)) + if (!originalAuthenticationInfo.UserId.Equals(Guid.Empty)) { - info.User = _userManager.GetUserById(tokenInfo.UserId); + authInfo.User = _userManager.GetUserById(originalAuthenticationInfo.UserId); - if (info.User != null && !string.Equals(info.User.Username, tokenInfo.UserName, StringComparison.OrdinalIgnoreCase)) + if (authInfo.User != null && !string.Equals(authInfo.User.Username, originalAuthenticationInfo.UserName, StringComparison.OrdinalIgnoreCase)) { - tokenInfo.UserName = info.User.Username; + originalAuthenticationInfo.UserName = authInfo.User.Username; updateToken = true; } } if (updateToken) { - _authRepo.Update(tokenInfo); + _authRepo.Update(originalAuthenticationInfo); } } - httpReq.Items["OriginalAuthenticationInfo"] = tokenInfo; } - httpReq.Items["AuthorizationInfo"] = info; - - return info; + return (authInfo, originalAuthenticationInfo); } /// @@ -176,7 +210,32 @@ namespace Emby.Server.Implementations.HttpServer.Security /// Dictionary{System.StringSystem.String}. private Dictionary GetAuthorizationDictionary(IRequest httpReq) { - var auth = httpReq.Headers["X-Emby-Authorization"]; + var auth = httpReq.Headers["X-Jellyfin-Authorization"]; + if (string.IsNullOrEmpty(auth)) + { + auth = httpReq.Headers["X-Emby-Authorization"]; + } + + if (string.IsNullOrEmpty(auth)) + { + auth = httpReq.Headers[HeaderNames.Authorization]; + } + + return GetAuthorization(auth); + } + + /// + /// Gets the auth. + /// + /// The HTTP req. + /// Dictionary{System.StringSystem.String}. + private Dictionary GetAuthorizationDictionary(HttpRequest httpReq) + { + var auth = httpReq.Headers["X-Jellyfin-Authorization"]; + if (string.IsNullOrEmpty(auth)) + { + auth = httpReq.Headers["X-Emby-Authorization"]; + } if (string.IsNullOrEmpty(auth)) { @@ -206,7 +265,7 @@ namespace Emby.Server.Implementations.HttpServer.Security return null; } - var acceptedNames = new[] { "MediaBrowser", "Emby" }; + var acceptedNames = new[] { "MediaBrowser", "Emby", "Jellyfin" }; // It has to be a digest request if (!acceptedNames.Contains(parts[0], StringComparer.OrdinalIgnoreCase)) @@ -236,12 +295,7 @@ namespace Emby.Server.Implementations.HttpServer.Security private static string NormalizeValue(string value) { - if (string.IsNullOrEmpty(value)) - { - return value; - } - - return WebUtility.HtmlEncode(value); + return string.IsNullOrEmpty(value) ? value : WebUtility.HtmlEncode(value); } } } diff --git a/MediaBrowser.Controller/Net/IAuthorizationContext.cs b/MediaBrowser.Controller/Net/IAuthorizationContext.cs index 61598391f..37a7425b9 100644 --- a/MediaBrowser.Controller/Net/IAuthorizationContext.cs +++ b/MediaBrowser.Controller/Net/IAuthorizationContext.cs @@ -1,7 +1,11 @@ using MediaBrowser.Model.Services; +using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller.Net { + /// + /// IAuthorization context. + /// public interface IAuthorizationContext { /// @@ -17,5 +21,12 @@ namespace MediaBrowser.Controller.Net /// The request context. /// AuthorizationInfo. AuthorizationInfo GetAuthorizationInfo(IRequest requestContext); + + /// + /// Gets the authorization information. + /// + /// The request context. + /// AuthorizationInfo. + AuthorizationInfo GetAuthorizationInfo(HttpRequest requestContext); } } -- cgit v1.2.3 From b451eb0bdc1594c88af11ae807fb7f3b3c4ef124 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Tue, 16 Jun 2020 16:45:17 -0600 Subject: Update Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs Co-authored-by: Patrick Barron <18354464+barronpm@users.noreply.github.com> --- Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs index deb9b5ebb..b9fca67bf 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs @@ -107,7 +107,7 @@ namespace Emby.Server.Implementations.HttpServer.Security token = queryString["ApiKey"]; } - // TODO depricate this query parameter. + // TODO deprecate this query parameter. if (string.IsNullOrEmpty(token)) { token = queryString["api_key"]; -- cgit v1.2.3 From 4962e230af13933f6a087b78b16884da0e485688 Mon Sep 17 00:00:00 2001 From: crobibero Date: Wed, 17 Jun 2020 06:52:15 -0600 Subject: revert adding Jellyfin to auth header --- .../HttpServer/Security/AuthorizationContext.cs | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs index b9fca67bf..fb93fae3e 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs @@ -87,11 +87,6 @@ namespace Emby.Server.Implementations.HttpServer.Security auth.TryGetValue("Token", out token); } - if (string.IsNullOrEmpty(token)) - { - token = headers["X-Jellyfin-Token"]; - } - if (string.IsNullOrEmpty(token)) { token = headers["X-Emby-Token"]; @@ -210,11 +205,7 @@ namespace Emby.Server.Implementations.HttpServer.Security /// Dictionary{System.StringSystem.String}. private Dictionary GetAuthorizationDictionary(IRequest httpReq) { - var auth = httpReq.Headers["X-Jellyfin-Authorization"]; - if (string.IsNullOrEmpty(auth)) - { - auth = httpReq.Headers["X-Emby-Authorization"]; - } + var auth = httpReq.Headers["X-Emby-Authorization"]; if (string.IsNullOrEmpty(auth)) { @@ -231,11 +222,7 @@ namespace Emby.Server.Implementations.HttpServer.Security /// Dictionary{System.StringSystem.String}. private Dictionary GetAuthorizationDictionary(HttpRequest httpReq) { - var auth = httpReq.Headers["X-Jellyfin-Authorization"]; - if (string.IsNullOrEmpty(auth)) - { - auth = httpReq.Headers["X-Emby-Authorization"]; - } + var auth = httpReq.Headers["X-Emby-Authorization"]; if (string.IsNullOrEmpty(auth)) { @@ -265,7 +252,7 @@ namespace Emby.Server.Implementations.HttpServer.Security return null; } - var acceptedNames = new[] { "MediaBrowser", "Emby", "Jellyfin" }; + var acceptedNames = new[] { "MediaBrowser", "Emby" }; // It has to be a digest request if (!acceptedNames.Contains(parts[0], StringComparer.OrdinalIgnoreCase)) -- cgit v1.2.3 From 64fb173dad77a38273548434bee683b85e323345 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 20 Jun 2020 15:59:41 +0200 Subject: Move DashboardController to Jellyfin.Api --- Emby.Server.Implementations/ApplicationHost.cs | 4 - .../Emby.Server.Implementations.csproj | 1 - Jellyfin.Api/Controllers/DashboardController.cs | 264 ++++++++++++++++ Jellyfin.Api/Models/ConfigurationPageInfo.cs | 85 ++++++ Jellyfin.Server/Program.cs | 4 +- .../Api/ConfigurationPageInfo.cs | 63 ---- MediaBrowser.WebDashboard/Api/DashboardService.cs | 340 --------------------- .../MediaBrowser.WebDashboard.csproj | 42 --- .../Properties/AssemblyInfo.cs | 21 -- MediaBrowser.WebDashboard/ServerEntryPoint.cs | 42 --- MediaBrowser.sln | 6 - 11 files changed, 351 insertions(+), 521 deletions(-) create mode 100644 Jellyfin.Api/Controllers/DashboardController.cs create mode 100644 Jellyfin.Api/Models/ConfigurationPageInfo.cs delete mode 100644 MediaBrowser.WebDashboard/Api/ConfigurationPageInfo.cs delete mode 100644 MediaBrowser.WebDashboard/Api/DashboardService.cs delete mode 100644 MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj delete mode 100644 MediaBrowser.WebDashboard/Properties/AssemblyInfo.cs delete mode 100644 MediaBrowser.WebDashboard/ServerEntryPoint.cs (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 5772dd479..25ee7e9ec 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -97,7 +97,6 @@ using MediaBrowser.Providers.Chapters; using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Plugins.TheTvdb; using MediaBrowser.Providers.Subtitles; -using MediaBrowser.WebDashboard.Api; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -1037,9 +1036,6 @@ namespace Emby.Server.Implementations // Include composable parts in the Api assembly yield return typeof(ApiEntryPoint).Assembly; - // Include composable parts in the Dashboard assembly - yield return typeof(DashboardService).Assembly; - // Include composable parts in the Model assembly yield return typeof(SystemInfo).Assembly; diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index e71e437ac..5272e2692 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -13,7 +13,6 @@ - diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs new file mode 100644 index 000000000..6a7bf7d0a --- /dev/null +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using Jellyfin.Api.Models; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Extensions; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Plugins; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; + +namespace Jellyfin.Api.Controllers +{ + /// + /// The dashboard controller. + /// + public class DashboardController : BaseJellyfinApiController + { + private readonly IServerApplicationHost _appHost; + private readonly IConfiguration _appConfig; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IResourceFileManager _resourceFileManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + public DashboardController( + IServerApplicationHost appHost, + IConfiguration appConfig, + IResourceFileManager resourceFileManager, + IServerConfigurationManager serverConfigurationManager) + { + _appHost = appHost; + _appConfig = appConfig; + _resourceFileManager = resourceFileManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// + /// Gets the path of the directory containing the static web interface content, or null if the server is not + /// hosting the web client. + /// + private string? WebClientUiPath => GetWebClientUiPath(_appConfig, _serverConfigurationManager); + + /// + /// Gets the configuration pages. + /// + /// Whether to enable in the main menu. + /// The . + /// ConfigurationPages returned. + /// Server still loading. + /// An with infos about the plugins. + [HttpGet("/web/ConfigurationPages")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult> GetConfigurationPages( + [FromQuery] bool? enableInMainMenu, + [FromQuery] ConfigurationPageType? pageType) + { + const string unavailableMessage = "The server is still loading. Please try again momentarily."; + + var pages = _appHost.GetExports().ToList(); + + if (pages == null) + { + return NotFound(unavailableMessage); + } + + // Don't allow a failing plugin to fail them all + var configPages = pages.Select(p => + { + return new ConfigurationPageInfo(p); + }) + .Where(i => i != null) + .ToList(); + + configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages)); + + if (pageType != null) + { + configPages = configPages.Where(p => p.ConfigurationPageType == pageType).ToList(); + } + + if (enableInMainMenu.HasValue) + { + configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList(); + } + + return configPages; + } + + /// + /// Gets a dashboard configuration page. + /// + /// The name of the page. + /// ConfigurationPage returned. + /// Plugin configuration page not found. + /// The configuration page. + [HttpGet("/web/ConfigurationPage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetDashboardConfigurationPage([FromQuery] string name) + { + IPlugin? plugin = null; + Stream? stream = null; + + var isJs = false; + var isTemplate = false; + + var page = _appHost.GetExports().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + if (page != null) + { + plugin = page.Plugin; + stream = page.GetHtmlStream(); + } + + if (plugin == null) + { + var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase)); + if (altPage != null) + { + plugin = altPage.Item2; + stream = plugin.GetType().Assembly.GetManifestResourceStream(altPage.Item1.EmbeddedResourcePath); + + isJs = string.Equals(Path.GetExtension(altPage.Item1.EmbeddedResourcePath), ".js", StringComparison.OrdinalIgnoreCase); + isTemplate = altPage.Item1.EmbeddedResourcePath.EndsWith(".template.html", StringComparison.Ordinal); + } + } + + if (plugin != null && stream != null) + { + if (isJs) + { + return File(stream, MimeTypes.GetMimeType("page.js")); + } + + if (isTemplate) + { + return File(stream, MimeTypes.GetMimeType("page.html")); + } + + return File(stream, MimeTypes.GetMimeType("page.html")); + } + + return NotFound(); + } + + /// + /// Gets the robots.txt. + /// + /// Robots.txt returned. + /// The robots.txt. + [HttpGet("/robots.txt")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ApiExplorerSettings(IgnoreApi = true)] + public ActionResult GetRobotsTxt() + { + return GetWebClientResource("robots.txt", string.Empty); + } + + /// + /// Gets a resource from the web client. + /// + /// The resource name. + /// The v. + /// Web client returned. + /// Server does not host a web client. + /// The resource. + [HttpGet("/web/{*resourceName}")] + [ApiExplorerSettings(IgnoreApi = true)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "v", Justification = "Imported from ServiceStack")] + public ActionResult GetWebClientResource( + [FromRoute] string resourceName, + [FromQuery] string? v) + { + if (!_appConfig.HostWebClient() || WebClientUiPath == null) + { + return NotFound("Server does not host a web client."); + } + + var path = resourceName; + var basePath = WebClientUiPath; + + // Bounce them to the startup wizard if it hasn't been completed yet + if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted + && !Request.Path.Value.Contains("wizard", StringComparison.OrdinalIgnoreCase) + && Request.Path.Value.Contains("index", StringComparison.OrdinalIgnoreCase)) + { + return Redirect("index.html?start=wizard#!/wizardstart.html"); + } + + var stream = new FileStream(_resourceFileManager.GetResourcePath(basePath, path), FileMode.Open, FileAccess.Read); + return File(stream, MimeTypes.GetMimeType(path)); + } + + /// + /// Gets the favicon. + /// + /// Favicon.ico returned. + /// The favicon. + [HttpGet("/favicon.ico")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ApiExplorerSettings(IgnoreApi = true)] + public ActionResult GetFavIcon() + { + return GetWebClientResource("favicon.ico", string.Empty); + } + + /// + /// Gets the path of the directory containing the static web interface content. + /// + /// The app configuration. + /// The server configuration manager. + /// The directory path, or null if the server is not hosting the web client. + public static string? GetWebClientUiPath(IConfiguration appConfig, IServerConfigurationManager serverConfigManager) + { + if (!appConfig.HostWebClient()) + { + return null; + } + + if (!string.IsNullOrEmpty(serverConfigManager.Configuration.DashboardSourcePath)) + { + return serverConfigManager.Configuration.DashboardSourcePath; + } + + return serverConfigManager.ApplicationPaths.WebPath; + } + + private IEnumerable GetConfigPages(IPlugin plugin) + { + return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1)); + } + + private IEnumerable> GetPluginPages(IPlugin plugin) + { + var hasConfig = plugin as IHasWebPages; + + if (hasConfig == null) + { + return new List>(); + } + + return hasConfig.GetPages().Select(i => new Tuple(i, plugin)); + } + + private IEnumerable> GetPluginPages() + { + return _appHost.Plugins.SelectMany(GetPluginPages); + } + } +} diff --git a/Jellyfin.Api/Models/ConfigurationPageInfo.cs b/Jellyfin.Api/Models/ConfigurationPageInfo.cs new file mode 100644 index 000000000..2aa6373aa --- /dev/null +++ b/Jellyfin.Api/Models/ConfigurationPageInfo.cs @@ -0,0 +1,85 @@ +using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Api.Models +{ + /// + /// The configuration page info. + /// + public class ConfigurationPageInfo + { + /// + /// Initializes a new instance of the class. + /// + /// Instance of interface. + public ConfigurationPageInfo(IPluginConfigurationPage page) + { + Name = page.Name; + + ConfigurationPageType = page.ConfigurationPageType; + + if (page.Plugin != null) + { + DisplayName = page.Plugin.Name; + // Don't use "N" because it needs to match Plugin.Id + PluginId = page.Plugin.Id.ToString(); + } + } + + /// + /// Initializes a new instance of the class. + /// + /// Instance of interface. + /// Instance of interface. + public ConfigurationPageInfo(IPlugin plugin, PluginPageInfo page) + { + Name = page.Name; + EnableInMainMenu = page.EnableInMainMenu; + MenuSection = page.MenuSection; + MenuIcon = page.MenuIcon; + DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin.Name : page.DisplayName; + + // Don't use "N" because it needs to match Plugin.Id + PluginId = plugin.Id.ToString(); + } + + /// + /// Gets or sets the name. + /// + /// The name. + public string Name { get; set; } + + /// + /// Gets or sets a value indicating whether the configurations page is enabled in the main menu. + /// + public bool EnableInMainMenu { get; set; } + + /// + /// Gets or sets the menu section. + /// + public string? MenuSection { get; set; } + + /// + /// Gets or sets the menu icon. + /// + public string? MenuIcon { get; set; } + + /// + /// Gets or sets the display name. + /// + public string? DisplayName { get; set; } + + /// + /// Gets or sets the type of the configuration page. + /// + /// The type of the configuration page. + public ConfigurationPageType ConfigurationPageType { get; set; } + + /// + /// Gets or sets the plugin id. + /// + /// The plugin id. + public string? PluginId { get; set; } + } +} diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 3971a08e9..dfc7bbbb1 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -14,9 +14,9 @@ using Emby.Server.Implementations; using Emby.Server.Implementations.HttpServer; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Networking; +using Jellyfin.Api.Controllers; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Extensions; -using MediaBrowser.WebDashboard.Api; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; @@ -172,7 +172,7 @@ namespace Jellyfin.Server // If hosting the web client, validate the client content path if (startupConfig.HostWebClient()) { - string webContentPath = DashboardService.GetDashboardUIPath(startupConfig, appHost.ServerConfigurationManager); + string? webContentPath = DashboardController.GetWebClientUiPath(startupConfig, appHost.ServerConfigurationManager); if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0) { throw new InvalidOperationException( diff --git a/MediaBrowser.WebDashboard/Api/ConfigurationPageInfo.cs b/MediaBrowser.WebDashboard/Api/ConfigurationPageInfo.cs deleted file mode 100644 index e49a4be8a..000000000 --- a/MediaBrowser.WebDashboard/Api/ConfigurationPageInfo.cs +++ /dev/null @@ -1,63 +0,0 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Common.Plugins; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Model.Plugins; - -namespace MediaBrowser.WebDashboard.Api -{ - public class ConfigurationPageInfo - { - public ConfigurationPageInfo(IPluginConfigurationPage page) - { - Name = page.Name; - - ConfigurationPageType = page.ConfigurationPageType; - - if (page.Plugin != null) - { - DisplayName = page.Plugin.Name; - // Don't use "N" because it needs to match Plugin.Id - PluginId = page.Plugin.Id.ToString(); - } - } - - public ConfigurationPageInfo(IPlugin plugin, PluginPageInfo page) - { - Name = page.Name; - EnableInMainMenu = page.EnableInMainMenu; - MenuSection = page.MenuSection; - MenuIcon = page.MenuIcon; - DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin.Name : page.DisplayName; - - // Don't use "N" because it needs to match Plugin.Id - PluginId = plugin.Id.ToString(); - } - - /// - /// Gets or sets the name. - /// - /// The name. - public string Name { get; set; } - - public bool EnableInMainMenu { get; set; } - - public string MenuSection { get; set; } - - public string MenuIcon { get; set; } - - public string DisplayName { get; set; } - - /// - /// Gets or sets the type of the configuration page. - /// - /// The type of the configuration page. - public ConfigurationPageType ConfigurationPageType { get; set; } - - /// - /// Gets or sets the plugin id. - /// - /// The plugin id. - public string PluginId { get; set; } - } -} diff --git a/MediaBrowser.WebDashboard/Api/DashboardService.cs b/MediaBrowser.WebDashboard/Api/DashboardService.cs deleted file mode 100644 index 63cbfd9e4..000000000 --- a/MediaBrowser.WebDashboard/Api/DashboardService.cs +++ /dev/null @@ -1,340 +0,0 @@ -#pragma warning disable CS1591 -#pragma warning disable SA1402 -#pragma warning disable SA1649 - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Plugins; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Extensions; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Plugins; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.WebDashboard.Api -{ - /// - /// Class GetDashboardConfigurationPages. - /// - [Route("/web/ConfigurationPages", "GET")] - public class GetDashboardConfigurationPages : IReturn> - { - /// - /// Gets or sets the type of the page. - /// - /// The type of the page. - public ConfigurationPageType? PageType { get; set; } - - public bool? EnableInMainMenu { get; set; } - } - - /// - /// Class GetDashboardConfigurationPage. - /// - [Route("/web/ConfigurationPage", "GET")] - public class GetDashboardConfigurationPage - { - /// - /// Gets or sets the name. - /// - /// The name. - public string Name { get; set; } - } - - [Route("/robots.txt", "GET", IsHidden = true)] - public class GetRobotsTxt - { - } - - /// - /// Class GetDashboardResource. - /// - [Route("/web/{ResourceName*}", "GET", IsHidden = true)] - public class GetDashboardResource - { - /// - /// Gets or sets the name. - /// - /// The name. - public string ResourceName { get; set; } - - /// - /// Gets or sets the V. - /// - /// The V. - public string V { get; set; } - } - - [Route("/favicon.ico", "GET", IsHidden = true)] - public class GetFavIcon - { - } - - /// - /// Class DashboardService. - /// - public class DashboardService : IService, IRequiresRequest - { - /// - /// Gets or sets the logger. - /// - /// The logger. - private readonly ILogger _logger; - - /// - /// Gets or sets the HTTP result factory. - /// - /// The HTTP result factory. - private readonly IHttpResultFactory _resultFactory; - private readonly IServerApplicationHost _appHost; - private readonly IConfiguration _appConfig; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IFileSystem _fileSystem; - private readonly IResourceFileManager _resourceFileManager; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The application host. - /// The application configuration. - /// The resource file manager. - /// The server configuration manager. - /// The file system. - /// The result factory. - public DashboardService( - ILogger logger, - IServerApplicationHost appHost, - IConfiguration appConfig, - IResourceFileManager resourceFileManager, - IServerConfigurationManager serverConfigurationManager, - IFileSystem fileSystem, - IHttpResultFactory resultFactory) - { - _logger = logger; - _appHost = appHost; - _appConfig = appConfig; - _resourceFileManager = resourceFileManager; - _serverConfigurationManager = serverConfigurationManager; - _fileSystem = fileSystem; - _resultFactory = resultFactory; - } - - /// - /// Gets or sets the request context. - /// - /// The request context. - public IRequest Request { get; set; } - - /// - /// Gets the path of the directory containing the static web interface content, or null if the server is not - /// hosting the web client. - /// - public string DashboardUIPath => GetDashboardUIPath(_appConfig, _serverConfigurationManager); - - /// - /// Gets the path of the directory containing the static web interface content. - /// - /// The app configuration. - /// The server configuration manager. - /// The directory path, or null if the server is not hosting the web client. - public static string GetDashboardUIPath(IConfiguration appConfig, IServerConfigurationManager serverConfigManager) - { - if (!appConfig.HostWebClient()) - { - return null; - } - - if (!string.IsNullOrEmpty(serverConfigManager.Configuration.DashboardSourcePath)) - { - return serverConfigManager.Configuration.DashboardSourcePath; - } - - return serverConfigManager.ApplicationPaths.WebPath; - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetFavIcon request) - { - return Get(new GetDashboardResource - { - ResourceName = "favicon.ico" - }); - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public Task Get(GetDashboardConfigurationPage request) - { - IPlugin plugin = null; - Stream stream = null; - - var isJs = false; - var isTemplate = false; - - var page = ServerEntryPoint.Instance.PluginConfigurationPages.FirstOrDefault(p => string.Equals(p.Name, request.Name, StringComparison.OrdinalIgnoreCase)); - if (page != null) - { - plugin = page.Plugin; - stream = page.GetHtmlStream(); - } - - if (plugin == null) - { - var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, request.Name, StringComparison.OrdinalIgnoreCase)); - if (altPage != null) - { - plugin = altPage.Item2; - stream = plugin.GetType().Assembly.GetManifestResourceStream(altPage.Item1.EmbeddedResourcePath); - - isJs = string.Equals(Path.GetExtension(altPage.Item1.EmbeddedResourcePath), ".js", StringComparison.OrdinalIgnoreCase); - isTemplate = altPage.Item1.EmbeddedResourcePath.EndsWith(".template.html", StringComparison.Ordinal); - } - } - - if (plugin != null && stream != null) - { - if (isJs) - { - return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.js"), () => Task.FromResult(stream)); - } - - if (isTemplate) - { - return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => Task.FromResult(stream)); - } - - return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => Task.FromResult(stream)); - } - - throw new ResourceNotFoundException(); - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public object Get(GetDashboardConfigurationPages request) - { - const string unavailableMessage = "The server is still loading. Please try again momentarily."; - - var instance = ServerEntryPoint.Instance; - - if (instance == null) - { - throw new InvalidOperationException(unavailableMessage); - } - - var pages = instance.PluginConfigurationPages; - - if (pages == null) - { - throw new InvalidOperationException(unavailableMessage); - } - - // Don't allow a failing plugin to fail them all - var configPages = pages.Select(p => - { - try - { - return new ConfigurationPageInfo(p); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting plugin information from {Plugin}", p.GetType().Name); - return null; - } - }) - .Where(i => i != null) - .ToList(); - - configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages)); - - if (request.PageType.HasValue) - { - configPages = configPages.Where(p => p.ConfigurationPageType == request.PageType.Value).ToList(); - } - - if (request.EnableInMainMenu.HasValue) - { - configPages = configPages.Where(p => p.EnableInMainMenu == request.EnableInMainMenu.Value).ToList(); - } - - return configPages; - } - - private IEnumerable> GetPluginPages() - { - return _appHost.Plugins.SelectMany(GetPluginPages); - } - - private IEnumerable> GetPluginPages(IPlugin plugin) - { - var hasConfig = plugin as IHasWebPages; - - if (hasConfig == null) - { - return new List>(); - } - - return hasConfig.GetPages().Select(i => new Tuple(i, plugin)); - } - - private IEnumerable GetConfigPages(IPlugin plugin) - { - return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1)); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetRobotsTxt request) - { - return Get(new GetDashboardResource - { - ResourceName = "robots.txt" - }); - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public async Task Get(GetDashboardResource request) - { - if (!_appConfig.HostWebClient() || DashboardUIPath == null) - { - throw new ResourceNotFoundException(); - } - - var path = request?.ResourceName; - var basePath = DashboardUIPath; - - // Bounce them to the startup wizard if it hasn't been completed yet - if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted - && !Request.RawUrl.Contains("wizard", StringComparison.OrdinalIgnoreCase) - && Request.RawUrl.Contains("index", StringComparison.OrdinalIgnoreCase)) - { - Request.Response.Redirect("index.html?start=wizard#!/wizardstart.html"); - return null; - } - - return await _resultFactory.GetStaticFileResult(Request, _resourceFileManager.GetResourcePath(basePath, path)).ConfigureAwait(false); - } - } -} diff --git a/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj b/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj deleted file mode 100644 index bcaee50f2..000000000 --- a/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj +++ /dev/null @@ -1,42 +0,0 @@ - - - - - {5624B7B5-B5A7-41D8-9F10-CC5611109619} - - - - - - - - - - - - - - PreserveNewest - - - - - netstandard2.1 - false - true - true - - - - - - - - - - - - ../jellyfin.ruleset - - - diff --git a/MediaBrowser.WebDashboard/Properties/AssemblyInfo.cs b/MediaBrowser.WebDashboard/Properties/AssemblyInfo.cs deleted file mode 100644 index 584d49021..000000000 --- a/MediaBrowser.WebDashboard/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Reflection; -using System.Resources; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("MediaBrowser.WebDashboard")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Jellyfin Project")] -[assembly: AssemblyProduct("Jellyfin Server")] -[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: NeutralResourcesLanguage("en")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] diff --git a/MediaBrowser.WebDashboard/ServerEntryPoint.cs b/MediaBrowser.WebDashboard/ServerEntryPoint.cs deleted file mode 100644 index 5c7e8b3c7..000000000 --- a/MediaBrowser.WebDashboard/ServerEntryPoint.cs +++ /dev/null @@ -1,42 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common; -using MediaBrowser.Controller.Plugins; - -namespace MediaBrowser.WebDashboard -{ - public sealed class ServerEntryPoint : IServerEntryPoint - { - private readonly IApplicationHost _appHost; - - public ServerEntryPoint(IApplicationHost appHost) - { - _appHost = appHost; - Instance = this; - } - - public static ServerEntryPoint Instance { get; private set; } - - /// - /// Gets the list of plugin configuration pages. - /// - /// The configuration pages. - public List PluginConfigurationPages { get; private set; } - - /// - public Task RunAsync() - { - PluginConfigurationPages = _appHost.GetExports().ToList(); - - return Task.CompletedTask; - } - - /// - public void Dispose() - { - } - } -} diff --git a/MediaBrowser.sln b/MediaBrowser.sln index e100c0b1c..0362eff1c 100644 --- a/MediaBrowser.sln +++ b/MediaBrowser.sln @@ -12,8 +12,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Common", "Medi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Model", "MediaBrowser.Model\MediaBrowser.Model.csproj", "{7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.WebDashboard", "MediaBrowser.WebDashboard\MediaBrowser.WebDashboard.csproj", "{5624B7B5-B5A7-41D8-9F10-CC5611109619}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Providers", "MediaBrowser.Providers\MediaBrowser.Providers.csproj", "{442B5058-DCAF-4263-BB6A-F21E31120A1B}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.XbmcMetadata", "MediaBrowser.XbmcMetadata\MediaBrowser.XbmcMetadata.csproj", "{23499896-B135-4527-8574-C26E926EA99E}" @@ -94,10 +92,6 @@ Global {7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}.Debug|Any CPU.Build.0 = Debug|Any CPU {7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}.Release|Any CPU.ActiveCfg = Release|Any CPU {7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}.Release|Any CPU.Build.0 = Release|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Release|Any CPU.Build.0 = Release|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Debug|Any CPU.Build.0 = Debug|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Release|Any CPU.ActiveCfg = Release|Any CPU -- cgit v1.2.3 From 3514813eb4eda997a0ea722cc2ed41979419c6dd Mon Sep 17 00:00:00 2001 From: David Date: Sun, 12 Jul 2020 11:14:38 +0200 Subject: Continute work --- Emby.Server.Implementations/ApplicationHost.cs | 3 + Jellyfin.Api/Controllers/AudioController.cs | 350 ++++++++--- Jellyfin.Api/Controllers/PlaystateController.cs | 12 +- Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs | 236 +++++++ Jellyfin.Api/Helpers/StreamingHelpers.cs | 681 +++++++++++++++++++-- Jellyfin.Api/Helpers/TranscodingJobHelper.cs | 453 +++++++++++++- Jellyfin.Api/Models/StreamState.cs | 145 ----- Jellyfin.Api/Models/StreamingDtos/StreamState.cs | 207 +++++++ .../Playback/Progressive/AudioService.cs | 4 - .../MediaEncoding/EncodingHelper.cs | 11 + 10 files changed, 1801 insertions(+), 301 deletions(-) create mode 100644 Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs delete mode 100644 Jellyfin.Api/Models/StreamState.cs create mode 100644 Jellyfin.Api/Models/StreamingDtos/StreamState.cs (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 25ee7e9ec..c177537b8 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -46,6 +46,7 @@ using Emby.Server.Implementations.Session; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; using Emby.Server.Implementations.SyncPlay; +using Jellyfin.Api.Helpers; using MediaBrowser.Api; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; @@ -637,6 +638,8 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + + serviceCollection.AddSingleton(); } /// diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index 39df1e1b1..4d29d3880 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -3,97 +3,277 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Helpers; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; +using Microsoft.Extensions.Configuration; namespace Jellyfin.Api.Controllers { - /// /// The audio controller. /// + // TODO: In order to autheneticate this in the future, Dlna playback will require updating public class AudioController : BaseJellyfinApiController { private readonly IDlnaManager _dlnaManager; - private readonly ILogger _logger; + private readonly IAuthorizationContext _authContext; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IStreamHelper _streamHelper; + private readonly IFileSystem _fileSystem; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IConfiguration _configuration; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + + private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; /// /// Initializes a new instance of the class. /// /// Instance of the interface. - /// Instance of the interface. - public AudioController(IDlnaManager dlnaManager, ILogger logger) + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// The singleton. + public AudioController( + IDlnaManager dlnaManager, + IUserManager userManger, + IAuthorizationContext authorizationContext, + ILibraryManager libraryManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IStreamHelper streamHelper, + IFileSystem fileSystem, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper) { _dlnaManager = dlnaManager; - _logger = logger; + _authContext = authorizationContext; + _userManager = userManger; + _libraryManager = libraryManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _streamHelper = streamHelper; + _fileSystem = fileSystem; + _subtitleEncoder = subtitleEncoder; + _configuration = configuration; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; } - [HttpGet("{id}/stream.{container}")] - [HttpGet("{id}/stream")] - [HttpHead("{id}/stream.{container}")] - [HttpGet("{id}/stream")] + /// + /// Gets an audio stream. + /// + /// The item id. + /// The audio container. + /// 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. + /// The streaming parameters. + /// The tag. + /// Optional. The dlna device profile id to utilize. + /// The play session id. + /// The segment container. + /// The segment lenght. + /// The minimum number of segments. + /// The media version id, if playing an alternate version. + /// The device id of the client requesting. Used to stop encoding processes when needed. + /// Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma. + /// Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true. + /// Whether or not to allow copying of the video stream url. + /// Whether or not to allow copying of the audio stream url. + /// Optional. Whether to break on non key frames. + /// Optional. Specify a specific audio sample rate, e.g. 44100. + /// Optional. The maximum audio bit depth. + /// Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults. + /// Optional. Specify a specific number of audio channels to encode to, e.g. 2. + /// Optional. Specify a maximum number of audio channels to encode to, e.g. 2. + /// Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high. + /// Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1. + /// Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements. + /// Whether or not to copy timestamps when transcoding with an offset. Defaults to false. + /// Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms. + /// Optional. The fixed horizontal resolution of the encoded video. + /// Optional. The fixed vertical resolution of the encoded video. + /// Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults. + /// Optional. The index of the subtitle stream to use. If omitted no subtitles will be used. + /// Optional. Specify the subtitle delivery method. + /// Optional. + /// Optional. The maximum video bit depth. + /// Optional. Whether to require avc. + /// Optional. Whether to deinterlace the video. + /// Optional. Whether to require a non anamporphic stream. + /// Optional. The maximum number of audio channels to transcode. + /// Optional. The limit of how many cpu cores to use. + /// The live stream id. + /// Optional. Whether to enable the MpegtsM2Ts mode. + /// Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv. + /// Optional. Specify a subtitle codec to encode to. + /// Optional. The transcoding reason. + /// Optional. The index of the audio stream to use. If omitted the first audio stream will be used. + /// Optional. The index of the video stream to use. If omitted the first video stream will be used. + /// Optional. The . + /// Optional. The streaming options. + /// A containing the audio file. + [HttpGet("{itemId}/stream.{container}")] + [HttpGet("{itemId}/stream")] + [HttpHead("{itemId}/stream.{container}")] + [HttpGet("{itemId}/stream")] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task GetAudioStream( - [FromRoute] string id, - [FromRoute] string container, - [FromQuery] bool Static, - [FromQuery] string tag) + [FromRoute] Guid itemId, + [FromRoute] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] 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] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodingReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext context, + [FromQuery] Dictionary streamOptions) { bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; var cancellationTokenSource = new CancellationTokenSource(); - var state = await GetState(request, cancellationTokenSource.Token).ConfigureAwait(false); + var state = await StreamingHelpers.GetStreamingState( + itemId, + startTimeTicks, + audioCodec, + subtitleCodec, + videoCodec, + @params, + @static, + container, + liveStreamId, + playSessionId, + mediaSourceId, + deviceId, + deviceProfileId, + audioBitRate, + Request, + _authContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _fileSystem, + _subtitleEncoder, + _configuration, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + _transcodingJobType, + false, + cancellationTokenSource.Token) + .ConfigureAwait(false); - if (Static && state.DirectStreamProvider != null) + if (@static.HasValue && @static.Value && state.DirectStreamProvider != null) { - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, Request, _dlnaManager); + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); using (state) { - var outputHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // TODO: Don't hardcode this - outputHeaders[HeaderNames.ContentType] = MimeTypes.GetMimeType("file.ts"); + // TODO AllowEndOfFile = false + await new ProgressiveFileCopier(_streamHelper, state.DirectStreamProvider).WriteToAsync(Response.Body, CancellationToken.None).ConfigureAwait(false); - return new ProgressiveFileCopier(state.DirectStreamProvider, outputHeaders, null, _logger, CancellationToken.None) - { - AllowEndOfFile = false - }; + // TODO (moved from MediaBrowser.Api): Don't hardcode contentType + return File(Response.Body, MimeTypes.GetMimeType("file.ts")!); } } // Static remote stream - if (Static && state.InputProtocol == MediaProtocol.Http) + if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http) { - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, Request, _dlnaManager); + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager); using (state) { - return await GetStaticRemoteStreamResult(state, responseHeaders, isHeadRequest, cancellationTokenSource).ConfigureAwait(false); + return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, this, cancellationTokenSource).ConfigureAwait(false); } } - if (Static && state.InputProtocol != MediaProtocol.File) + if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File) { - throw new ArgumentException(string.Format($"Input protocol {state.InputProtocol} cannot be streamed statically.")); + return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically"); } var outputPath = state.OutputFilePath; - var outputPathExists = File.Exists(outputPath); + var outputPathExists = System.IO.File.Exists(outputPath); - var transcodingJob = TranscodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); + var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); var isTranscodeCached = outputPathExists && transcodingJob != null; - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, Static || isTranscodeCached, Request, _dlnaManager); + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, startTimeTicks, Request, _dlnaManager); // Static stream - if (Static) + if (@static.HasValue && @static.Value) { var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); @@ -101,16 +281,10 @@ namespace Jellyfin.Api.Controllers { if (state.MediaSource.IsInfiniteStream) { - var outputHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [HeaderNames.ContentType] = contentType - }; - + // TODO AllowEndOfFile = false + await new ProgressiveFileCopier(_streamHelper, state.MediaPath).WriteToAsync(Response.Body, CancellationToken.None).ConfigureAwait(false); - return new ProgressiveFileCopier(FileSystem, state.MediaPath, outputHeaders, null, _logger, CancellationToken.None) - { - AllowEndOfFile = false - }; + return File(Response.Body, contentType); } TimeSpan? cacheDuration = null; @@ -120,57 +294,65 @@ namespace Jellyfin.Api.Controllers cacheDuration = TimeSpan.FromDays(365); } + return FileStreamResponseHelpers.GetStaticFileResult( + state.MediaPath, + contentType, + _fileSystem.GetLastWriteTimeUtc(state.MediaPath), + cacheDuration, + isHeadRequest, + this); + } + } + + /* + // Not static but transcode cache file exists + if (isTranscodeCached && state.VideoRequest == null) + { + var contentType = state.GetMimeType(outputPath) + try + { + if (transcodingJob != null) + { + ApiEntryPoint.Instance.OnTranscodeBeginRequest(transcodingJob); + } return await ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions { ResponseHeaders = responseHeaders, ContentType = contentType, IsHeadRequest = isHeadRequest, - Path = state.MediaPath, - CacheDuration = cacheDuration - + Path = outputPath, + FileShare = FileShare.ReadWrite, + OnComplete = () => + { + if (transcodingJob != null) + { + ApiEntryPoint.Instance.OnTranscodeEndRequest(transcodingJob); + } }).ConfigureAwait(false); } + finally + { + state.Dispose(); + } } + */ - //// Not static but transcode cache file exists - //if (isTranscodeCached && state.VideoRequest == null) - //{ - // var contentType = state.GetMimeType(outputPath); - - // try - // { - // if (transcodingJob != null) - // { - // ApiEntryPoint.Instance.OnTranscodeBeginRequest(transcodingJob); - // } - - // return await ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions - // { - // ResponseHeaders = responseHeaders, - // ContentType = contentType, - // IsHeadRequest = isHeadRequest, - // Path = outputPath, - // FileShare = FileShare.ReadWrite, - // OnComplete = () => - // { - // if (transcodingJob != null) - // { - // ApiEntryPoint.Instance.OnTranscodeEndRequest(transcodingJob); - // } - // } - - // }).ConfigureAwait(false); - // } - // finally - // { - // state.Dispose(); - // } - //} - - // Need to start ffmpeg + // Need to start ffmpeg (because media can't be returned directly) try { - return await GetStreamResult(request, state, responseHeaders, isHeadRequest, cancellationTokenSource).ConfigureAwait(false); + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + var ffmpegCommandLineArguments = encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); + return await FileStreamResponseHelpers.GetTranscodedFile( + state, + isHeadRequest, + _streamHelper, + this, + _transcodingJobHelper, + ffmpegCommandLineArguments, + Request, + _transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); } catch { diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 05a6edf4e..da69ca72c 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -8,7 +8,6 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -40,8 +39,7 @@ namespace Jellyfin.Api.Controllers /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. - /// Instance of the interface. + /// Th singleton. public PlaystateController( IUserManager userManager, IUserDataManager userDataRepository, @@ -49,8 +47,7 @@ namespace Jellyfin.Api.Controllers ISessionManager sessionManager, IAuthorizationContext authContext, ILoggerFactory loggerFactory, - IMediaSourceManager mediaSourceManager, - IFileSystem fileSystem) + TranscodingJobHelper transcodingJobHelper) { _userManager = userManager; _userDataRepository = userDataRepository; @@ -59,10 +56,7 @@ namespace Jellyfin.Api.Controllers _authContext = authContext; _logger = loggerFactory.CreateLogger(); - _transcodingJobHelper = new TranscodingJobHelper( - loggerFactory.CreateLogger(), - mediaSourceManager, - fileSystem); + _transcodingJobHelper = transcodingJobHelper; } /// diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs new file mode 100644 index 000000000..e03cafe35 --- /dev/null +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -0,0 +1,236 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.IO; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Api.Helpers +{ + /// + /// The stream response helpers. + /// + public static class FileStreamResponseHelpers + { + /// + /// Returns a static file from a remote source. + /// + /// The current . + /// Whether the current request is a HTTP HEAD request so only the headers get returned. + /// The managing the response. + /// The . + /// A containing the API response. + public static async Task GetStaticRemoteStreamResult( + StreamState state, + bool isHeadRequest, + ControllerBase controller, + CancellationTokenSource cancellationTokenSource) + { + HttpClient httpClient = new HttpClient(); + var responseHeaders = controller.Response.Headers; + + if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) + { + httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent); + } + + var response = await httpClient.GetAsync(state.MediaPath).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType.ToString(); + + responseHeaders[HeaderNames.AcceptRanges] = "none"; + + // Seeing cases of -1 here + if (response.Content.Headers.ContentLength.HasValue && response.Content.Headers.ContentLength.Value >= 0) + { + responseHeaders[HeaderNames.ContentLength] = response.Content.Headers.ContentLength.Value.ToString(CultureInfo.InvariantCulture); + } + + if (isHeadRequest) + { + using (response) + { + return controller.File(Array.Empty(), contentType); + } + } + + return controller.File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType); + } + + /// + /// Returns a static file from the server. + /// + /// The path to the file. + /// The content type of the file. + /// The of the last modification of the file. + /// The cache duration of the file. + /// Whether the current request is a HTTP HEAD request so only the headers get returned. + /// The managing the response. + /// An the file. + // TODO: caching doesn't work + public static ActionResult GetStaticFileResult( + string path, + string contentType, + DateTime dateLastModified, + TimeSpan? cacheDuration, + bool isHeadRequest, + ControllerBase controller) + { + bool disableCaching = false; + if (controller.Request.Headers.TryGetValue(HeaderNames.CacheControl, out StringValues headerValue)) + { + disableCaching = headerValue.FirstOrDefault().Contains("no-cache", StringComparison.InvariantCulture); + } + + bool parsingSuccessful = DateTime.TryParseExact(controller.Request.Headers[HeaderNames.IfModifiedSince], "ddd, dd MMM yyyy HH:mm:ss \"GMT\"", new CultureInfo("en-US", false), DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime ifModifiedSinceHeader); + + // if the parsing of the IfModifiedSince header was not successfull, disable caching + if (!parsingSuccessful) + { + disableCaching = true; + } + + controller.Response.ContentType = contentType; + controller.Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateLastModified).TotalSeconds).ToString(CultureInfo.InvariantCulture)); + controller.Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept); + + if (disableCaching) + { + controller.Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate"); + controller.Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate"); + } + else + { + if (cacheDuration.HasValue) + { + controller.Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds); + } + else + { + controller.Response.Headers.Add(HeaderNames.CacheControl, "public"); + } + + controller.Response.Headers.Add(HeaderNames.LastModified, dateLastModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", new CultureInfo("en-US", false))); + + // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified + if (!(dateLastModified > ifModifiedSinceHeader)) + { + if (ifModifiedSinceHeader.Add(cacheDuration!.Value) < DateTime.UtcNow) + { + controller.Response.StatusCode = StatusCodes.Status304NotModified; + return new ContentResult(); + } + } + } + + // if the request is a head request, return a NoContent result with the same headers as it would with a GET request + if (isHeadRequest) + { + return controller.NoContent(); + } + + var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + return controller.File(stream, contentType); + } + + /// + /// Returns a transcoded file from the server. + /// + /// The current . + /// Whether the current request is a HTTP HEAD request so only the headers get returned. + /// Instance of the interface. + /// The managing the response. + /// The singleton. + /// The command line arguments to start ffmpeg. + /// The starting the transcoding. + /// The . + /// The . + /// A containing the transcoded file. + public static async Task GetTranscodedFile( + StreamState state, + bool isHeadRequest, + IStreamHelper streamHelper, + ControllerBase controller, + TranscodingJobHelper transcodingJobHelper, + string ffmpegCommandLineArguments, + HttpRequest request, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource) + { + IHeaderDictionary responseHeaders = controller.Response.Headers; + // Use the command line args with a dummy playlist path + var outputPath = state.OutputFilePath; + + responseHeaders[HeaderNames.AcceptRanges] = "none"; + + var contentType = state.GetMimeType(outputPath); + + // TODO: The isHeadRequest is only here because ServiceStack will add Content-Length=0 to the response + // TODO (from api-migration): Investigate if this is still neccessary as we migrated away from ServiceStack + var contentLength = state.EstimateContentLength || isHeadRequest ? GetEstimatedContentLength(state) : null; + + if (contentLength.HasValue) + { + responseHeaders[HeaderNames.ContentLength] = contentLength.Value.ToString(CultureInfo.InvariantCulture); + } + else + { + responseHeaders.Remove(HeaderNames.ContentLength); + } + + // Headers only + if (isHeadRequest) + { + return controller.File(Array.Empty(), contentType); + } + + var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath); + await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + try + { + if (!File.Exists(outputPath)) + { + await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); + } + else + { + transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive); + state.Dispose(); + } + + Stream stream = new MemoryStream(); + + await new ProgressiveFileCopier(streamHelper, outputPath).WriteToAsync(stream, CancellationToken.None).ConfigureAwait(false); + return controller.File(stream, contentType); + } + finally + { + transcodingLock.Release(); + } + } + + /// + /// Gets the length of the estimated content. + /// + /// The state. + /// System.Nullable{System.Int64}. + private static long? GetEstimatedContentLength(StreamState state) + { + var totalBitrate = state.TotalOutputBitrate ?? 0; + + if (totalBitrate > 0 && state.RunTimeTicks.HasValue) + { + return Convert.ToInt64(totalBitrate * TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalSeconds / 8); + } + + return null; + } + } +} diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 4cebf40f6..c88ec0b2f 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -1,32 +1,255 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; -using Jellyfin.Api.Models; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; namespace Jellyfin.Api.Helpers { /// - /// The streaming helpers + /// The streaming helpers. /// - public class StreamingHelpers + public static class StreamingHelpers { + public static async Task GetStreamingState( + Guid itemId, + long? startTimeTicks, + string? audioCodec, + string? subtitleCodec, + string? videoCodec, + string? @params, + bool? @static, + string? container, + string? liveStreamId, + string? playSessionId, + string? mediaSourceId, + string? deviceId, + string? deviceProfileId, + int? audioBitRate, + HttpRequest request, + IAuthorizationContext authorizationContext, + IMediaSourceManager mediaSourceManager, + IUserManager userManager, + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + IDlnaManager dlnaManager, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + TranscodingJobType transcodingJobType, + bool isVideoRequest, + CancellationToken cancellationToken) + { + EncodingHelper encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); + // Parse the DLNA time seek header + if (!startTimeTicks.HasValue) + { + var timeSeek = request.Headers["TimeSeekRange.dlna.org"]; + + startTimeTicks = ParseTimeSeekHeader(timeSeek); + } + + if (!string.IsNullOrWhiteSpace(@params)) + { + // What is this? + ParseParams(request); + } + + var streamOptions = ParseStreamOptions(request.Query); + + var url = request.Path.Value.Split('.').Last(); + + if (string.IsNullOrEmpty(audioCodec)) + { + audioCodec = encodingHelper.InferAudioCodec(url); + } + + var enableDlnaHeaders = !string.IsNullOrWhiteSpace(@params) || + string.Equals(request.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase); + + var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper) + { + // TODO request was the StreamingRequest living in MediaBrowser.Api.Playback.Progressive + Request = request, + RequestedUrl = url, + UserAgent = request.Headers[HeaderNames.UserAgent], + EnableDlnaHeaders = enableDlnaHeaders + }; + + var auth = authorizationContext.GetAuthorizationInfo(request); + if (!auth.UserId.Equals(Guid.Empty)) + { + state.User = userManager.GetUserById(auth.UserId); + } + + /* + if ((Request.UserAgent ?? string.Empty).IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 || + (Request.UserAgent ?? string.Empty).IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 || + (Request.UserAgent ?? string.Empty).IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) + { + state.SegmentLength = 6; + } + */ + + if (state.VideoRequest != null && !string.IsNullOrWhiteSpace(state.VideoRequest.VideoCodec)) + { + state.SupportedVideoCodecs = state.VideoRequest.VideoCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); + state.VideoRequest.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); + } + + if (!string.IsNullOrWhiteSpace(audioCodec)) + { + state.SupportedAudioCodecs = audioCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); + state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToAudioCodec(i)) + ?? state.SupportedAudioCodecs.FirstOrDefault(); + } + + if (!string.IsNullOrWhiteSpace(subtitleCodec)) + { + state.SupportedSubtitleCodecs = subtitleCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); + state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(i => mediaEncoder.CanEncodeToSubtitleCodec(i)) + ?? state.SupportedSubtitleCodecs.FirstOrDefault(); + } + + var item = libraryManager.GetItemById(itemId); + + state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); + + /* + var primaryImage = item.GetImageInfo(ImageType.Primary, 0) ?? + item.Parents.Select(i => i.GetImageInfo(ImageType.Primary, 0)).FirstOrDefault(i => i != null); + if (primaryImage != null) + { + state.AlbumCoverPath = primaryImage.Path; + } + */ + + MediaSourceInfo? mediaSource = null; + if (string.IsNullOrWhiteSpace(liveStreamId)) + { + var currentJob = !string.IsNullOrWhiteSpace(playSessionId) + ? transcodingJobHelper.GetTranscodingJob(playSessionId) + : null; + + if (currentJob != null) + { + mediaSource = currentJob.MediaSource; + } + + if (mediaSource == null) + { + var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(itemId), null, false, false, cancellationToken).ConfigureAwait(false); + + mediaSource = string.IsNullOrEmpty(mediaSourceId) + ? mediaSources[0] + : mediaSources.Find(i => string.Equals(i.Id, mediaSourceId, StringComparison.InvariantCulture)); + + if (mediaSource == null && Guid.Parse(mediaSourceId) == itemId) + { + mediaSource = mediaSources[0]; + } + } + } + else + { + var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(liveStreamId, cancellationToken).ConfigureAwait(false); + mediaSource = liveStreamInfo.Item1; + state.DirectStreamProvider = liveStreamInfo.Item2; + } + + encodingHelper.AttachMediaSourceInfo(state, mediaSource, url); + + var containerInternal = Path.GetExtension(state.RequestedUrl); + + if (string.IsNullOrEmpty(container)) + { + containerInternal = container; + } + + if (string.IsNullOrEmpty(containerInternal)) + { + containerInternal = (@static.HasValue && @static.Value) ? StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, state.MediaPath, null, DlnaProfileType.Audio) : GetOutputFileExtension(state); + } + + state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); + + state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(audioBitRate, state.AudioStream); + + state.OutputAudioCodec = audioCodec; + + state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); + + if (isVideoRequest) + { + state.OutputVideoCodec = state.VideoRequest.VideoCodec; + state.OutputVideoBitrate = EncodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); + + encodingHelper.TryStreamCopy(state); + + if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + var resolution = ResolutionNormalizer.Normalize( + state.VideoStream?.BitRate, + state.VideoStream?.Width, + state.VideoStream?.Height, + state.OutputVideoBitrate.Value, + state.VideoStream?.Codec, + state.OutputVideoCodec, + videoRequest.MaxWidth, + videoRequest.MaxHeight); + + videoRequest.MaxWidth = resolution.MaxWidth; + videoRequest.MaxHeight = resolution.MaxHeight; + } + } + + ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, request, deviceProfileId, @static); + + var ext = string.IsNullOrWhiteSpace(state.OutputContainer) + ? GetOutputFileExtension(state) + : ('.' + state.OutputContainer); + + state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, deviceId, playSessionId); + + return state; + } + /// /// Adds the dlna headers. /// /// The state. /// The response headers. /// if set to true [is statically streamed]. + /// The start time in ticks. /// The . /// Instance of the interface. public static void AddDlnaHeaders( StreamState state, IHeaderDictionary responseHeaders, bool isStaticallyStreamed, + long? startTimeTicks, HttpRequest request, IDlnaManager dlnaManager) { @@ -54,7 +277,7 @@ namespace Jellyfin.Api.Helpers if (!isStaticallyStreamed && profile != null) { - AddTimeSeekResponseHeaders(state, responseHeaders); + AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks); } } @@ -82,51 +305,18 @@ namespace Jellyfin.Api.Helpers { var videoCodec = state.ActualOutputVideoCodec; - responseHeaders.Add("contentFeatures.dlna.org", new ContentFeatureBuilder(profile).BuildVideoHeader( - state.OutputContainer, - videoCodec, - audioCodec, - state.OutputWidth, - state.OutputHeight, - state.TargetVideoBitDepth, - state.OutputVideoBitrate, - state.TargetTimestamp, - isStaticallyStreamed, - state.RunTimeTicks, - state.TargetVideoProfile, - state.TargetVideoLevel, - state.TargetFramerate, - state.TargetPacketLength, - state.TranscodeSeekInfo, - state.IsTargetAnamorphic, - state.IsTargetInterlaced, - state.TargetRefFrames, - state.TargetVideoStreamCount, - state.TargetAudioStreamCount, - state.TargetVideoCodecTag, - state.IsTargetAVC).FirstOrDefault() ?? string.Empty); - } - } - - /// - /// Parses the dlna headers. - /// - /// The start time ticks. - /// The . - public void ParseDlnaHeaders(long? startTimeTicks, HttpRequest request) - { - if (!startTimeTicks.HasValue) - { - var timeSeek = request.Headers["TimeSeekRange.dlna.org"]; - - startTimeTicks = ParseTimeSeekHeader(timeSeek); + responseHeaders.Add( + "contentFeatures.dlna.org", + new ContentFeatureBuilder(profile).BuildVideoHeader(state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty); } } /// /// Parses the time seek header. /// - public long? ParseTimeSeekHeader(string value) + /// The time seek header string. + /// A nullable representing the seek time in ticks. + public static long? ParseTimeSeekHeader(string value) { if (string.IsNullOrWhiteSpace(value)) { @@ -138,12 +328,13 @@ namespace Jellyfin.Api.Helpers { throw new ArgumentException("Invalid timeseek header"); } - int index = value.IndexOf('-'); + + int index = value.IndexOf('-', StringComparison.InvariantCulture); value = index == -1 ? value.Substring(Npt.Length) : value.Substring(Npt.Length, index - Npt.Length); - if (value.IndexOf(':') == -1) + if (value.IndexOf(':', StringComparison.InvariantCulture) == -1) { // Parses npt times in the format of '417.33' if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) @@ -169,15 +360,45 @@ namespace Jellyfin.Api.Helpers { throw new ArgumentException("Invalid timeseek header"); } + timeFactor /= 60; } + return TimeSpan.FromSeconds(secondsSum).Ticks; } - public void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders) + /// + /// Parses query parameters as StreamOptions. + /// + /// The query string. + /// A containing the stream options. + public static Dictionary ParseStreamOptions(IQueryCollection queryString) { - var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture); - var startSeconds = TimeSpan.FromTicks(state.Request.StartTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture); + Dictionary streamOptions = new Dictionary(); + 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; + } + + /// + /// Adds the dlna time seek headers to the response. + /// + /// The current . + /// The of the response. + /// The start time in ticks. + public static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks) + { + var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture); + var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture); responseHeaders.Add("TimeSeekRange.dlna.org", string.Format( CultureInfo.InvariantCulture, @@ -190,5 +411,369 @@ namespace Jellyfin.Api.Helpers startSeconds, runtimeSeconds)); } + + /// + /// Gets the output file extension. + /// + /// The state. + /// System.String. + public static string? GetOutputFileExtension(StreamState state) + { + var ext = Path.GetExtension(state.RequestedUrl); + + if (!string.IsNullOrEmpty(ext)) + { + return ext; + } + + var isVideoRequest = state.VideoRequest != null; + + // Try to infer based on the desired video codec + if (isVideoRequest) + { + var videoCodec = state.VideoRequest.VideoCodec; + + if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) || + string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase)) + { + return ".ts"; + } + + if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase)) + { + return ".ogv"; + } + + if (string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) + { + return ".webm"; + } + + if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase)) + { + return ".asf"; + } + } + + // Try to infer based on the desired audio codec + if (!isVideoRequest) + { + 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"; + } + } + + return null; + } + + /// + /// Gets the output file path for transcoding. + /// + /// The current . + /// The file extension of the output file. + /// Instance of the interface. + /// The device id. + /// The play session id. + /// The complete file path, including the folder, for the transcoding file. + 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); + } + + private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static) + { + var headers = request.Headers; + + if (!string.IsNullOrWhiteSpace(deviceProfileId)) + { + state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId); + } + else if (!string.IsNullOrWhiteSpace(deviceProfileId)) + { + var caps = deviceManager.GetCapabilities(deviceProfileId); + + state.DeviceProfile = caps == null ? dlnaManager.GetProfile(headers) : caps.DeviceProfile; + } + + var profile = state.DeviceProfile; + + if (profile == null) + { + // Don't use settings from the default profile. + // Only use a specific profile if it was requested. + return; + } + + var audioCodec = state.ActualOutputAudioCodec; + var videoCodec = state.ActualOutputVideoCodec; + + var mediaProfile = state.VideoRequest == null + ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth) + : profile.GetVideoMediaProfile( + state.OutputContainer, + audioCodec, + videoCodec, + state.OutputWidth, + state.OutputHeight, + state.TargetVideoBitDepth, + state.OutputVideoBitrate, + state.TargetVideoProfile, + state.TargetVideoLevel, + state.TargetFramerate, + state.TargetPacketLength, + state.TargetTimestamp, + state.IsTargetAnamorphic, + state.IsTargetInterlaced, + state.TargetRefFrames, + state.TargetVideoStreamCount, + state.TargetAudioStreamCount, + state.TargetVideoCodecTag, + state.IsTargetAVC); + + if (mediaProfile != null) + { + state.MimeType = mediaProfile.MimeType; + } + + if (!(@static.HasValue && @static.Value)) + { + var transcodingProfile = state.VideoRequest == null ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec); + + if (transcodingProfile != null) + { + state.EstimateContentLength = transcodingProfile.EstimateContentLength; + // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; + state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; + + if (state.VideoRequest != null) + { + state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps; + state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest; + } + } + } + } + + /// + /// Parses the parameters. + /// + /// The request. + private void ParseParams(StreamRequest request) + { + var vals = request.Params.Split(';'); + + var videoRequest = request as VideoStreamRequest; + + for (var i = 0; i < vals.Length; i++) + { + var val = vals[i]; + + if (string.IsNullOrWhiteSpace(val)) + { + continue; + } + + switch (i) + { + case 0: + request.DeviceProfileId = val; + 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 != null) + { + videoRequest.VideoCodec = val; + } + + break; + case 5: + request.AudioCodec = val; + break; + case 6: + if (videoRequest != null) + { + videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 7: + if (videoRequest != null) + { + videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 8: + if (videoRequest != 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 != null) + { + videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 12: + if (videoRequest != null) + { + videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 13: + if (videoRequest != null) + { + videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 14: + request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); + break; + case 15: + if (videoRequest != null) + { + videoRequest.Level = val; + } + + break; + case 16: + if (videoRequest != null) + { + videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 17: + if (videoRequest != null) + { + videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture); + } + + break; + case 18: + if (videoRequest != 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 != null) + { + videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + + break; + case 25: + if (!string.IsNullOrWhiteSpace(val) && videoRequest != 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 != null) + { + videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + + break; + case 28: + request.Tag = val; + break; + case 29: + if (videoRequest != null) + { + videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + + break; + case 30: + request.SubtitleCodec = val; + break; + case 31: + if (videoRequest != null) + { + videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + + break; + case 32: + if (videoRequest != null) + { + videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + + break; + case 33: + request.TranscodeReasons = val; + break; + } + } + } } } diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 7db75387a..9fbd5ec2d 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -1,16 +1,28 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Models; using Jellyfin.Api.Models.PlaybackDtos; +using Jellyfin.Api.Models.StreamingDtos; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Session; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Helpers @@ -30,9 +42,17 @@ namespace Jellyfin.Api.Helpers /// private static readonly Dictionary _transcodingLocks = new Dictionary(); + private readonly IAuthorizationContext _authorizationContext; + private readonly EncodingHelper _encodingHelper; + private readonly IFileSystem _fileSystem; + private readonly IIsoManager _isoManager; + private readonly ILogger _logger; + private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; - private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ISessionManager _sessionManager; + private readonly ILoggerFactory _loggerFactory; /// /// Initializes a new instance of the class. @@ -40,14 +60,40 @@ namespace Jellyfin.Api.Helpers /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. public TranscodingJobHelper( ILogger logger, IMediaSourceManager mediaSourceManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IMediaEncoder mediaEncoder, + IServerConfigurationManager serverConfigurationManager, + ISessionManager sessionManager, + IAuthorizationContext authorizationContext, + IIsoManager isoManager, + ISubtitleEncoder subtitleEncoder, + IConfiguration configuration, + ILoggerFactory loggerFactory) { _logger = logger; _mediaSourceManager = mediaSourceManager; _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + _serverConfigurationManager = serverConfigurationManager; + _sessionManager = sessionManager; + _authorizationContext = authorizationContext; + _isoManager = isoManager; + _loggerFactory = loggerFactory; + + _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); + + DeleteEncodedMediaCache(); } /// @@ -63,7 +109,13 @@ namespace Jellyfin.Api.Helpers } } - public static TranscodingJobDto GetTranscodingJob(string path, TranscodingJobType type) + /// + /// Get transcoding job. + /// + /// Path to the transcoding file. + /// The . + /// The transcoding job. + public TranscodingJobDto GetTranscodingJob(string path, TranscodingJobType type) { lock (_activeTranscodingJobs) { @@ -361,14 +413,24 @@ namespace Jellyfin.Api.Helpers } } + /// + /// Report the transcoding progress to the session manager. + /// + /// The of which the progress will be reported. + /// The of the current transcoding job. + /// The current transcoding position. + /// The framerate of the transcoding job. + /// The completion percentage of the transcode. + /// The number of bytes transcoded. + /// The bitrate of the transcoding job. public void ReportTranscodingProgress( - TranscodingJob job, - StreamState state, - TimeSpan? transcodingPosition, - float? framerate, - double? percentComplete, - long? bytesTranscoded, - int? bitRate) + TranscodingJobDto job, + StreamState state, + TimeSpan? transcodingPosition, + float? framerate, + double? percentComplete, + long? bytesTranscoded, + int? bitRate) { var ticks = transcodingPosition?.Ticks; @@ -405,5 +467,374 @@ namespace Jellyfin.Api.Helpers }); } } + + /// + /// Starts the FFMPEG. + /// + /// The state. + /// The output path. + /// The command line arguments for ffmpeg. + /// The . + /// The . + /// The cancellation token source. + /// The working directory. + /// Task. + public async Task StartFfMpeg( + StreamState state, + string outputPath, + string commandLineArguments, + HttpRequest request, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource, + string workingDirectory = null) + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + + await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); + + if (state.VideoRequest != null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + var auth = _authorizationContext.GetAuthorizationInfo(request); + if (auth.User != null && !auth.User.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) + { + this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + + throw new ArgumentException("User does not have access to video transcoding"); + } + } + + var process = new Process() + { + StartInfo = new ProcessStartInfo() + { + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + UseShellExecute = false, + + // Must consume both stdout and stderr or deadlocks may occur + // RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + FileName = _mediaEncoder.EncoderPath, + Arguments = commandLineArguments, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory, + ErrorDialog = false + }, + EnableRaisingEvents = true + }; + + var transcodingJob = this.OnTranscodeBeginning( + outputPath, + state.Request.PlaySessionId, + state.MediaSource.LiveStreamId, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), + transcodingJobType, + process, + state.Request.DeviceId, + state, + cancellationTokenSource); + + var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + _logger.LogInformation(commandLineLogMessage); + + var logFilePrefix = "ffmpeg-transcode"; + if (state.VideoRequest != null + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) + ? "ffmpeg-remux" + : "ffmpeg-directstream"; + } + + var logFilePath = Path.Combine(_serverConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt"); + + // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true); + + var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); + await logStream.WriteAsync(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length, cancellationTokenSource.Token).ConfigureAwait(false); + + process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state); + + try + { + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting ffmpeg"); + + this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + + throw; + } + + _logger.LogDebug("Launched ffmpeg process"); + state.TranscodingJob = transcodingJob; + + // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback + _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream); + + // Wait for the file to exist before proceeeding + var ffmpegTargetFile = state.WaitForPath ?? outputPath; + _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); + while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) + { + await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false); + } + + _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile); + + if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited) + { + await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false); + + if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited) + { + await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + + if (!transcodingJob.HasExited) + { + StartThrottler(state, transcodingJob); + } + + _logger.LogDebug("StartFfMpeg() finished successfully"); + + return transcodingJob; + } + + private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob) + { + if (EnableThrottling(state)) + { + transcodingJob.TranscodingThrottler = state.TranscodingThrottler = new TranscodingThrottler(transcodingJob, new Logger(new LoggerFactory()), _serverConfigurationManager, _fileSystem); + state.TranscodingThrottler.Start(); + } + } + + private bool EnableThrottling(StreamState state) + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + + // enable throttling when NOT using hardware acceleration + if (string.IsNullOrEmpty(encodingOptions.HardwareAccelerationType)) + { + return state.InputProtocol == MediaProtocol.File && + state.RunTimeTicks.HasValue && + state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks && + state.IsInputVideo && + state.VideoType == VideoType.VideoFile && + !EncodingHelper.IsCopyCodec(state.OutputVideoCodec); + } + + return false; + } + + /// + /// Called when [transcode beginning]. + /// + /// The path. + /// The play session identifier. + /// The live stream identifier. + /// The transcoding job identifier. + /// The type. + /// The process. + /// The device id. + /// The state. + /// The cancellation token source. + /// TranscodingJob. + public TranscodingJobDto OnTranscodeBeginning( + string path, + string playSessionId, + string liveStreamId, + string transcodingJobId, + TranscodingJobType type, + Process process, + string deviceId, + StreamState state, + CancellationTokenSource cancellationTokenSource) + { + lock (_activeTranscodingJobs) + { + var job = new TranscodingJobDto(_loggerFactory.CreateLogger()) + { + Type = type, + Path = path, + Process = process, + ActiveRequestCount = 1, + DeviceId = deviceId, + CancellationTokenSource = cancellationTokenSource, + Id = transcodingJobId, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + MediaSource = state.MediaSource + }; + + _activeTranscodingJobs.Add(job); + + ReportTranscodingProgress(job, state, null, null, null, null, null); + + return job; + } + } + + /// + /// + /// The progressive + /// + /// Called when [transcode failed to start]. + /// + /// The path. + /// The type. + /// The state. + public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state) + { + lock (_activeTranscodingJobs) + { + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + + if (job != null) + { + _activeTranscodingJobs.Remove(job); + } + } + + lock (_transcodingLocks) + { + _transcodingLocks.Remove(path); + } + + if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) + { + _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); + } + } + + /// + /// Processes the exited. + /// + /// The process. + /// The job. + /// The state. + private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state) + { + if (job != null) + { + job.HasExited = true; + } + + _logger.LogDebug("Disposing stream resources"); + state.Dispose(); + + if (process.ExitCode == 0) + { + _logger.LogInformation("FFMpeg exited with code 0"); + } + else + { + _logger.LogError("FFMpeg exited with code {0}", process.ExitCode); + } + + process.Dispose(); + } + + private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) + { + if (state.VideoType == VideoType.Iso && state.IsoType.HasValue && _isoManager.CanMount(state.MediaPath)) + { + state.IsoMount = await _isoManager.Mount(state.MediaPath, cancellationTokenSource.Token).ConfigureAwait(false); + } + + if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) + { + var liveStreamResponse = await _mediaSourceManager.OpenLiveStream( + new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + _encodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl); + + if (state.VideoRequest != null) + { + _encodingHelper.TryStreamCopy(state); + } + } + + if (state.MediaSource.BufferMs.HasValue) + { + await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + + /// + /// Called when [transcode begin request]. + /// + /// The path. + /// The type. + /// The . + public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) + { + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + + if (job == null) + { + return null; + } + + OnTranscodeBeginRequest(job); + + return job; + } + } + + private void OnTranscodeBeginRequest(TranscodingJobDto job) + { + job.ActiveRequestCount++; + + if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) + { + job.StopKillTimer(); + } + } + + /// + /// Gets the transcoding lock. + /// + /// The output path of the transcoded file. + /// A . + public SemaphoreSlim GetTranscodingLock(string outputPath) + { + lock (_transcodingLocks) + { + if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim result)) + { + result = new SemaphoreSlim(1, 1); + _transcodingLocks[outputPath] = result; + } + + return result; + } + } + + /// + /// Deletes the encoded media cache. + /// + private void DeleteEncodedMediaCache() + { + var path = _serverConfigurationManager.GetTranscodePath(); + if (!Directory.Exists(path)) + { + return; + } + + foreach (var file in _fileSystem.GetFilePaths(path, true)) + { + _fileSystem.DeleteFile(file); + } + } } } diff --git a/Jellyfin.Api/Models/StreamState.cs b/Jellyfin.Api/Models/StreamState.cs deleted file mode 100644 index 9fe5f52c3..000000000 --- a/Jellyfin.Api/Models/StreamState.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System; -using Jellyfin.Api.Helpers; -using Jellyfin.Api.Models.PlaybackDtos; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Dlna; - -namespace Jellyfin.Api.Models -{ - public class StreamState : EncodingJobInfo, IDisposable - { - private readonly IMediaSourceManager _mediaSourceManager; - private bool _disposed = false; - - public string RequestedUrl { get; set; } - - public StreamRequest Request - { - get => (StreamRequest)BaseRequest; - set - { - BaseRequest = value; - - IsVideoRequest = VideoRequest != null; - } - } - - public TranscodingThrottler TranscodingThrottler { get; set; } - - public VideoStreamRequest VideoRequest => Request as VideoStreamRequest; - - public IDirectStreamProvider DirectStreamProvider { get; set; } - - public string WaitForPath { get; set; } - - public bool IsOutputVideo => Request is VideoStreamRequest; - - public int SegmentLength - { - get - { - if (Request.SegmentLength.HasValue) - { - return Request.SegmentLength.Value; - } - - if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) - { - var userAgent = UserAgent ?? string.Empty; - - if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1 || - userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1 || - userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 || - userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 || - userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) - { - if (IsSegmentedLiveStream) - { - return 6; - } - - return 6; - } - - if (IsSegmentedLiveStream) - { - return 3; - } - - return 6; - } - - return 3; - } - } - - public int MinSegments - { - get - { - if (Request.MinSegments.HasValue) - { - return Request.MinSegments.Value; - } - - return SegmentLength >= 10 ? 2 : 3; - } - } - - public string UserAgent { get; set; } - - public bool EstimateContentLength { get; set; } - - public TranscodeSeekInfo TranscodeSeekInfo { get; set; } - - public bool EnableDlnaHeaders { get; set; } - - public DeviceProfile DeviceProfile { get; set; } - - public TranscodingJobDto TranscodingJob { get; set; } - - public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType) - : base(transcodingType) - { - _mediaSourceManager = mediaSourceManager; - } - - public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) - { - TranscodingJobHelper.ReportTranscodingProgress(TranscodingJob, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - // REVIEW: Is this the right place for this? - if (MediaSource.RequiresClosing - && string.IsNullOrWhiteSpace(Request.LiveStreamId) - && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) - { - _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); - } - - TranscodingThrottler?.Dispose(); - } - - TranscodingThrottler = null; - TranscodingJob = null; - - _disposed = true; - } - } -} diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs new file mode 100644 index 000000000..b962e0ac7 --- /dev/null +++ b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs @@ -0,0 +1,207 @@ +using System; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; + +namespace Jellyfin.Api.Models.StreamingDtos +{ + /// + /// The stream state dto. + /// + public class StreamState : EncodingJobInfo, IDisposable + { + private readonly IMediaSourceManager _mediaSourceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// The . + /// The singleton. + public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType, TranscodingJobHelper transcodingJobHelper) + : base(transcodingType) + { + _mediaSourceManager = mediaSourceManager; + _transcodingJobHelper = transcodingJobHelper; + } + + /// + /// Gets or sets the requested url. + /// + public string? RequestedUrl { get; set; } + + // /// + // /// Gets or sets the request. + // /// + // public StreamRequest Request + // { + // get => (StreamRequest)BaseRequest; + // set + // { + // BaseRequest = value; + // + // IsVideoRequest = VideoRequest != null; + // } + // } + + /// + /// Gets or sets the transcoding throttler. + /// + public TranscodingThrottler? TranscodingThrottler { get; set; } + + /// + /// Gets the video request. + /// + public VideoStreamRequest VideoRequest => Request as VideoStreamRequest; + + /// + /// Gets or sets the direct stream provicer. + /// + public IDirectStreamProvider? DirectStreamProvider { get; set; } + + /// + /// Gets or sets the path to wait for. + /// + public string? WaitForPath { get; set; } + + /// + /// Gets a value indicating whether the request outputs video. + /// + public bool IsOutputVideo => Request is VideoStreamRequest; + + /// + /// Gets the segment length. + /// + public int SegmentLength + { + get + { + if (Request.SegmentLength.HasValue) + { + return Request.SegmentLength.Value; + } + + if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) + { + var userAgent = UserAgent ?? string.Empty; + + if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1 || + userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1 || + userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 || + userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 || + userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) + { + if (IsSegmentedLiveStream) + { + return 6; + } + + return 6; + } + + if (IsSegmentedLiveStream) + { + return 3; + } + + return 6; + } + + return 3; + } + } + + /// + /// Gets the minimum number of segments. + /// + public int MinSegments + { + get + { + if (Request.MinSegments.HasValue) + { + return Request.MinSegments.Value; + } + + return SegmentLength >= 10 ? 2 : 3; + } + } + + /// + /// Gets or sets the user agent. + /// + public string? UserAgent { get; set; } + + /// + /// Gets or sets a value indicating whether to estimate the content length. + /// + public bool EstimateContentLength { get; set; } + + /// + /// Gets or sets the transcode seek info. + /// + public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + + /// + /// Gets or sets a value indicating whether to enable dlna headers. + /// + public bool EnableDlnaHeaders { get; set; } + + /// + /// Gets or sets the device profile. + /// + public DeviceProfile? DeviceProfile { get; set; } + + /// + /// Gets or sets the transcoding job. + /// + public TranscodingJobDto? TranscodingJob { get; set; } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + { + _transcodingJobHelper.ReportTranscodingProgress(TranscodingJob!, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); + } + + /// + /// Disposes the stream state. + /// + /// Whether the object is currently beeing disposed. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + // REVIEW: Is this the right place for this? + if (MediaSource.RequiresClosing + && string.IsNullOrWhiteSpace(Request.LiveStreamId) + && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) + { + _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); + } + + TranscodingThrottler?.Dispose(); + } + + TranscodingThrottler = null; + TranscodingJob = null; + + _disposed = true; + } + } +} diff --git a/MediaBrowser.Api/Playback/Progressive/AudioService.cs b/MediaBrowser.Api/Playback/Progressive/AudioService.cs index 34c7986ca..ef639851b 100644 --- a/MediaBrowser.Api/Playback/Progressive/AudioService.cs +++ b/MediaBrowser.Api/Playback/Progressive/AudioService.cs @@ -17,10 +17,6 @@ namespace MediaBrowser.Api.Playback.Progressive /// /// Class GetAudioStream /// - [Route("/Audio/{Id}/stream.{Container}", "GET", Summary = "Gets an audio stream")] - [Route("/Audio/{Id}/stream", "GET", Summary = "Gets an audio stream")] - [Route("/Audio/{Id}/stream.{Container}", "HEAD", Summary = "Gets an audio stream")] - [Route("/Audio/{Id}/stream", "HEAD", Summary = "Gets an audio stream")] public class GetAudioStream : StreamRequest { } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 8ce106469..8cfe562b3 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1263,6 +1263,17 @@ namespace MediaBrowser.Controller.MediaEncoding return null; } + public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream) + { + if (audioBitRate.HasValue) + { + // Don't encode any higher than this + return Math.Min(384000, audioBitRate.Value); + } + + return null; + } + public string GetAudioFilterParam(EncodingJobInfo state, EncodingOptions encodingOptions, bool isHls) { var channels = state.OutputAudioChannels; -- cgit v1.2.3 From 5580df38e62ba75762da2f2b3ed4acd69b66e391 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 22 Jul 2020 11:05:41 +0200 Subject: Cleanup after merge --- Emby.Server.Implementations/ApplicationHost.cs | 2 -- 1 file changed, 2 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 34a4cc575..ad6cbe167 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -46,7 +45,6 @@ using Emby.Server.Implementations.Session; using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; -using Emby.Server.Implementations.SyncPlay; using Jellyfin.Api.Helpers; using MediaBrowser.Api; using MediaBrowser.Common; -- cgit v1.2.3 From dee7bdddb6f5ce0cc09dc2b20d4dab9747eea9f0 Mon Sep 17 00:00:00 2001 From: crobibero Date: Mon, 3 Aug 2020 14:49:24 -0600 Subject: fix build --- Emby.Server.Implementations/ApplicationHost.cs | 4 ---- 1 file changed, 4 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 7647827fb..0201ed7a3 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -46,7 +46,6 @@ using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; using Jellyfin.Api.Helpers; -using MediaBrowser.Api; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; @@ -1032,9 +1031,6 @@ namespace Emby.Server.Implementations } } - // Include composable parts in the Api assembly - yield return typeof(ApiEntryPoint).Assembly; - // Include composable parts in the Model assembly yield return typeof(SystemInfo).Assembly; -- cgit v1.2.3 From 8f6c2e767906c1d4c62d51ae2e66af1a78edde9a Mon Sep 17 00:00:00 2001 From: crobibero Date: Tue, 4 Aug 2020 08:27:54 -0600 Subject: Remove leading slash from route attributes --- .../Services/SwaggerService.cs | 287 --------------------- Jellyfin.Api/Controllers/ActivityLogController.cs | 2 +- Jellyfin.Api/Controllers/ApiKeyController.cs | 2 +- Jellyfin.Api/Controllers/ArtistsController.cs | 2 +- Jellyfin.Api/Controllers/CollectionController.cs | 2 +- Jellyfin.Api/Controllers/ItemRefreshController.cs | 3 +- .../Controllers/LibraryStructureController.cs | 2 +- Jellyfin.Api/Controllers/SearchController.cs | 2 +- Jellyfin.Api/Controllers/SystemController.cs | 2 +- Jellyfin.Api/Controllers/TimeSyncController.cs | 2 +- Jellyfin.Api/Controllers/TvShowsController.cs | 2 +- Jellyfin.Api/Controllers/UserController.cs | 2 +- 12 files changed, 11 insertions(+), 299 deletions(-) delete mode 100644 Emby.Server.Implementations/Services/SwaggerService.cs (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Services/SwaggerService.cs b/Emby.Server.Implementations/Services/SwaggerService.cs deleted file mode 100644 index 4f011a678..000000000 --- a/Emby.Server.Implementations/Services/SwaggerService.cs +++ /dev/null @@ -1,287 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Linq; -using Emby.Server.Implementations.HttpServer; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Services; - -namespace Emby.Server.Implementations.Services -{ - [Route("/swagger", "GET", Summary = "Gets the swagger specifications")] - [Route("/swagger.json", "GET", Summary = "Gets the swagger specifications")] - public class GetSwaggerSpec : IReturn - { - } - - public class SwaggerSpec - { - public string swagger { get; set; } - - public string[] schemes { get; set; } - - public SwaggerInfo info { get; set; } - - public string host { get; set; } - - public string basePath { get; set; } - - public SwaggerTag[] tags { get; set; } - - public IDictionary> paths { get; set; } - - public Dictionary definitions { get; set; } - - public SwaggerComponents components { get; set; } - } - - public class SwaggerComponents - { - public Dictionary securitySchemes { get; set; } - } - - public class SwaggerSecurityScheme - { - public string name { get; set; } - - public string type { get; set; } - - public string @in { get; set; } - } - - public class SwaggerInfo - { - public string description { get; set; } - - public string version { get; set; } - - public string title { get; set; } - - public string termsOfService { get; set; } - - public SwaggerConcactInfo contact { get; set; } - } - - public class SwaggerConcactInfo - { - public string email { get; set; } - - public string name { get; set; } - - public string url { get; set; } - } - - public class SwaggerTag - { - public string description { get; set; } - - public string name { get; set; } - } - - public class SwaggerMethod - { - public string summary { get; set; } - - public string description { get; set; } - - public string[] tags { get; set; } - - public string operationId { get; set; } - - public string[] consumes { get; set; } - - public string[] produces { get; set; } - - public SwaggerParam[] parameters { get; set; } - - public Dictionary responses { get; set; } - - public Dictionary[] security { get; set; } - } - - public class SwaggerParam - { - public string @in { get; set; } - - public string name { get; set; } - - public string description { get; set; } - - public bool required { get; set; } - - public string type { get; set; } - - public string collectionFormat { get; set; } - } - - public class SwaggerResponse - { - public string description { get; set; } - - // ex. "$ref":"#/definitions/Pet" - public Dictionary schema { get; set; } - } - - public class SwaggerDefinition - { - public string type { get; set; } - - public Dictionary properties { get; set; } - } - - public class SwaggerProperty - { - public string type { get; set; } - - public string format { get; set; } - - public string description { get; set; } - - public string[] @enum { get; set; } - - public string @default { get; set; } - } - - public class SwaggerService : IService, IRequiresRequest - { - private readonly IHttpServer _httpServer; - private SwaggerSpec _spec; - - public IRequest Request { get; set; } - - public SwaggerService(IHttpServer httpServer) - { - _httpServer = httpServer; - } - - public object Get(GetSwaggerSpec request) - { - return _spec ?? (_spec = GetSpec()); - } - - private SwaggerSpec GetSpec() - { - string host = null; - Uri uri; - if (Uri.TryCreate(Request.RawUrl, UriKind.Absolute, out uri)) - { - host = uri.Host; - } - - var securitySchemes = new Dictionary(); - - securitySchemes["api_key"] = new SwaggerSecurityScheme - { - name = "api_key", - type = "apiKey", - @in = "query" - }; - - var spec = new SwaggerSpec - { - schemes = new[] { "http" }, - tags = GetTags(), - swagger = "2.0", - info = new SwaggerInfo - { - title = "Jellyfin Server API", - version = "1.0.0", - description = "Explore the Jellyfin Server API", - contact = new SwaggerConcactInfo - { - name = "Jellyfin Community", - url = "https://jellyfin.readthedocs.io/en/latest/user-docs/getting-help/" - } - }, - paths = GetPaths(), - definitions = GetDefinitions(), - basePath = "/jellyfin", - host = host, - - components = new SwaggerComponents - { - securitySchemes = securitySchemes - } - }; - - return spec; - } - - - private SwaggerTag[] GetTags() - { - return Array.Empty(); - } - - private Dictionary GetDefinitions() - { - return new Dictionary(); - } - - private IDictionary> GetPaths() - { - var paths = new SortedDictionary>(); - - // REVIEW: this can be done better - var all = ((HttpListenerHost)_httpServer).ServiceController.RestPathMap.OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase).ToList(); - - foreach (var current in all) - { - foreach (var info in current.Value) - { - if (info.IsHidden) - { - continue; - } - - if (info.Path.StartsWith("/mediabrowser", StringComparison.OrdinalIgnoreCase) - || info.Path.StartsWith("/jellyfin", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - paths[info.Path] = GetPathInfo(info); - } - } - - return paths; - } - - private Dictionary GetPathInfo(RestPath info) - { - var result = new Dictionary(); - - foreach (var verb in info.Verbs) - { - var responses = new Dictionary - { - { "200", new SwaggerResponse { description = "OK" } } - }; - - var apiKeySecurity = new Dictionary - { - { "api_key", Array.Empty() } - }; - - result[verb.ToLowerInvariant()] = new SwaggerMethod - { - summary = info.Summary, - description = info.Description, - produces = new[] { "application/json" }, - consumes = new[] { "application/json" }, - operationId = info.RequestType.Name, - tags = Array.Empty(), - - parameters = Array.Empty(), - - responses = responses, - - security = new[] { apiKeySecurity } - }; - } - - return result; - } - } -} diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index 12ea24973..a07cea9c0 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -13,7 +13,7 @@ namespace Jellyfin.Api.Controllers /// /// Activity log controller. /// - [Route("/System/ActivityLog")] + [Route("System/ActivityLog")] [Authorize(Policy = Policies.RequiresElevation)] public class ActivityLogController : BaseJellyfinApiController { diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index fef4d7262..ccb7f47f0 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Api.Controllers /// /// Authentication controller. /// - [Route("/Auth")] + [Route("Auth")] public class ApiKeyController : BaseJellyfinApiController { private readonly ISessionManager _sessionManager; diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index d39021446..9d1010803 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -19,7 +19,7 @@ namespace Jellyfin.Api.Controllers /// The artists controller. /// [Authorize(Policy = Policies.DefaultAuthorization)] - [Route("/Artists")] + [Route("Artists")] public class ArtistsController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index 6f78a7d84..1e8426df4 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -16,7 +16,7 @@ namespace Jellyfin.Api.Controllers /// The collection controller. /// [Authorize(Policy = Policies.DefaultAuthorization)] - [Route("/Collections")] + [Route("Collections")] public class CollectionController : BaseJellyfinApiController { private readonly ICollectionManager _collectionManager; diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index 4697d869d..3f5d305c1 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -13,8 +13,7 @@ namespace Jellyfin.Api.Controllers /// /// Item Refresh Controller. /// - /// [Authenticated] - [Route("/Items")] + [Route("Items")] [Authorize(Policy = Policies.DefaultAuthorization)] public class ItemRefreshController : BaseJellyfinApiController { diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 827879e0a..ca150f3f2 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -24,7 +24,7 @@ namespace Jellyfin.Api.Controllers /// /// The library structure controller. /// - [Route("/Library/VirtualFolders")] + [Route("Library/VirtualFolders")] [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] public class LibraryStructureController : BaseJellyfinApiController { diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 2cbd32d2f..e159a9666 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -23,7 +23,7 @@ namespace Jellyfin.Api.Controllers /// /// Search controller. /// - [Route("/Search/Hints")] + [Route("Search/Hints")] [Authorize(Policy = Policies.DefaultAuthorization)] public class SearchController : BaseJellyfinApiController { diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index e0bce3a41..6f9a75e2f 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -23,7 +23,7 @@ namespace Jellyfin.Api.Controllers /// /// The system controller. /// - [Route("/System")] + [Route("System")] public class SystemController : BaseJellyfinApiController { private readonly IServerApplicationHost _appHost; diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index 57a720b26..bbabcd6e6 100644 --- a/Jellyfin.Api/Controllers/TimeSyncController.cs +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -9,7 +9,7 @@ namespace Jellyfin.Api.Controllers /// /// The time sync controller. /// - [Route("/GetUtcTime")] + [Route("GetUtcTime")] public class TimeSyncController : BaseJellyfinApiController { /// diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 508b5e24e..d4560dfa2 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -22,7 +22,7 @@ namespace Jellyfin.Api.Controllers /// /// The tv shows controller. /// - [Route("/Shows")] + [Route("Shows")] [Authorize(Policy = Policies.DefaultAuthorization)] public class TvShowsController : BaseJellyfinApiController { diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index ce0c9281b..482baf641 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -27,7 +27,7 @@ namespace Jellyfin.Api.Controllers /// /// User controller. /// - [Route("/Users")] + [Route("Users")] public class UserController : BaseJellyfinApiController { private readonly IUserManager _userManager; -- cgit v1.2.3 From 2b7cefdf15cf15a3e2e9f8499af7a008ea4a9cca Mon Sep 17 00:00:00 2001 From: crobibero Date: Tue, 4 Aug 2020 08:42:09 -0600 Subject: Remove references to MediaBrowser.Api --- Emby.Server.Implementations/Emby.Server.Implementations.csproj | 1 - tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj | 2 -- tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj | 1 - 3 files changed, 4 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index a1103b051..d3e212be1 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -15,7 +15,6 @@ - diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj index 05bb36832..4011e4aa8 100644 --- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj +++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj @@ -33,8 +33,6 @@ - - diff --git a/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj b/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj index 8309faebd..93bc8433a 100644 --- a/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj +++ b/tests/MediaBrowser.Api.Tests/MediaBrowser.Api.Tests.csproj @@ -17,7 +17,6 @@ - -- cgit v1.2.3