From 5da88fac4d0681126bdee635d59237d8d7fcebeb Mon Sep 17 00:00:00 2001 From: crobibero Date: Sun, 19 Apr 2020 11:24:32 -0600 Subject: Enable string enum converter --- .../Extensions/ApiServiceCollectionExtensions.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 71ef9a69a..a4f078b5b 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json.Serialization; using Jellyfin.Api; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; @@ -75,6 +80,9 @@ namespace Jellyfin.Server.Extensions { // Setting the naming policy to null leaves the property names as-is when serializing objects to JSON. options.JsonSerializerOptions.PropertyNamingPolicy = null; + + // Accept string enums + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }) .AddControllersAsServices(); } @@ -89,6 +97,17 @@ namespace Jellyfin.Server.Extensions return serviceCollection.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); + + // Add all xml doc files to swagger generator. + var xmlFiles = Directory.GetFiles( + AppContext.BaseDirectory, + "*.xml", + SearchOption.TopDirectoryOnly); + + foreach (var xmlFile in xmlFiles) + { + c.IncludeXmlComments(xmlFile); + } }); } } -- cgit v1.2.3 From c89dc8921ffb0ce11031e9cfb096b525d94e21b3 Mon Sep 17 00:00:00 2001 From: crobibero Date: Sun, 19 Apr 2020 18:10:59 -0600 Subject: Fix PascalCase --- .../Extensions/ApiServiceCollectionExtensions.cs | 3 +++ .../Formatters/PascalCaseJsonProfileFormatter.cs | 23 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 71ef9a69a..00688074f 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; +using Jellyfin.Server.Formatters; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -66,6 +67,8 @@ namespace Jellyfin.Server.Extensions return serviceCollection.AddMvc(opts => { opts.UseGeneralRoutePrefix(baseUrl); + opts.OutputFormatters.Insert(0, new CamelCaseJsonProfileFormatter()); + opts.OutputFormatters.Insert(0, new PascalCaseJsonProfileFormatter()); }) // Clear app parts to avoid other assemblies being picked up diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs new file mode 100644 index 000000000..2ed006a33 --- /dev/null +++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Server.Formatters +{ + /// + /// Pascal Case Json Profile Formatter. + /// + public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter + { + /// + /// Initializes a new instance of the class. + /// + public PascalCaseJsonProfileFormatter() : base(new JsonSerializerOptions { PropertyNamingPolicy = null }) + { + SupportedMediaTypes.Clear(); + // Add application/json for default formatter + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json")); + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"PascalCase\"")); + } + } +} -- cgit v1.2.3 From fff2a40ffc4e5010b26143185c68d221225c1a22 Mon Sep 17 00:00:00 2001 From: crobibero Date: Mon, 20 Apr 2020 07:24:13 -0600 Subject: Remove StringEnumConverter --- Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs | 3 --- 1 file changed, 3 deletions(-) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index a4f078b5b..92bacb440 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -80,9 +80,6 @@ namespace Jellyfin.Server.Extensions { // Setting the naming policy to null leaves the property names as-is when serializing objects to JSON. options.JsonSerializerOptions.PropertyNamingPolicy = null; - - // Accept string enums - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }) .AddControllersAsServices(); } -- cgit v1.2.3 From 068368df6352cfad4e69df599c364b3f05b367ba Mon Sep 17 00:00:00 2001 From: crobibero Date: Sun, 26 Apr 2020 23:28:32 -0600 Subject: Order actions by route, then http method --- Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 92bacb440..00a73ade6 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -105,6 +105,10 @@ namespace Jellyfin.Server.Extensions { c.IncludeXmlComments(xmlFile); } + + // Order actions by route path, then by http method. + c.OrderActionsBy(description => + $"{description.ActionDescriptor.RouteValues["controller"]}_{description.HttpMethod}"); }); } } -- 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 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') 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 2b1b9a64b6b7a3bac4d96642cda7a0c55d5cae74 Mon Sep 17 00:00:00 2001 From: crobibero Date: Fri, 8 May 2020 08:40:37 -0600 Subject: Add OperationId to SwaggerGen --- Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index a354f45aa..344ef6a5f 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Text.Json.Serialization; using Jellyfin.Api; using Jellyfin.Api.Auth; @@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; namespace Jellyfin.Server.Extensions { @@ -112,6 +114,10 @@ namespace Jellyfin.Server.Extensions // Order actions by route path, then by http method. c.OrderActionsBy(description => $"{description.ActionDescriptor.RouteValues["controller"]}_{description.HttpMethod}"); + + // Use method name as operationId + c.CustomOperationIds(description => + description.TryGetMethodInfo(out MethodInfo methodInfo) ? methodInfo.Name : null); }); } } -- cgit v1.2.3 From b944b8f8c54963f61eee5eeb97cd1745ae42ac50 Mon Sep 17 00:00:00 2001 From: crobibero Date: Mon, 1 Jun 2020 11:03:08 -0600 Subject: Enable CORS and Authentication. --- .../Extensions/ApiServiceCollectionExtensions.cs | 8 +++++- Jellyfin.Server/Models/ServerCorsPolicy.cs | 30 ++++++++++++++++++++++ Jellyfin.Server/Startup.cs | 4 ++- 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 Jellyfin.Server/Models/ServerCorsPolicy.cs (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 344ef6a5f..239c71503 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; using Jellyfin.Server.Formatters; +using Jellyfin.Server.Models; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -71,7 +72,12 @@ namespace Jellyfin.Server.Extensions /// The MVC builder. public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl) { - return serviceCollection.AddMvc(opts => + return serviceCollection + .AddCors(options => + { + options.AddPolicy(ServerCorsPolicy.DefaultPolicyName, ServerCorsPolicy.DefaultPolicy); + }) + .AddMvc(opts => { opts.UseGeneralRoutePrefix(baseUrl); opts.OutputFormatters.Insert(0, new CamelCaseJsonProfileFormatter()); diff --git a/Jellyfin.Server/Models/ServerCorsPolicy.cs b/Jellyfin.Server/Models/ServerCorsPolicy.cs new file mode 100644 index 000000000..ae010c042 --- /dev/null +++ b/Jellyfin.Server/Models/ServerCorsPolicy.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Cors.Infrastructure; + +namespace Jellyfin.Server.Models +{ + /// + /// Server Cors Policy. + /// + public static class ServerCorsPolicy + { + /// + /// Default policy name. + /// + public const string DefaultPolicyName = "DefaultCorsPolicy"; + + /// + /// Default Policy. Allow Everything. + /// + public static readonly CorsPolicy DefaultPolicy = new CorsPolicy + { + // Allow any origin + Origins = { "*" }, + + // Allow any method + Methods = { "*" }, + + // Allow any header + Headers = { "*" } + }; + } +} \ No newline at end of file diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 7c49afbfc..bd2887e4a 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,5 +1,6 @@ using Jellyfin.Server.Extensions; using Jellyfin.Server.Middleware; +using Jellyfin.Server.Models; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; @@ -68,9 +69,10 @@ namespace Jellyfin.Server // TODO app.UseMiddleware(); app.Use(serverApplicationHost.ExecuteWebsocketHandlerAsync); - // TODO use when old API is removed: app.UseAuthentication(); + app.UseAuthentication(); app.UseJellyfinApiSwagger(_serverConfigurationManager); app.UseRouting(); + app.UseCors(ServerCorsPolicy.DefaultPolicyName); app.UseAuthorization(); app.UseEndpoints(endpoints => { -- cgit v1.2.3 From dfe873fc293cf940a4f3d25aacdc8dfc53f150dc Mon Sep 17 00:00:00 2001 From: crobibero Date: Mon, 1 Jun 2020 11:12:33 -0600 Subject: Add Authentication to openapi generation. --- .../Extensions/ApiServiceCollectionExtensions.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 344ef6a5f..a6817421a 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -99,6 +99,25 @@ namespace Jellyfin.Server.Extensions return serviceCollection.AddSwaggerGen(c => { c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API" }); + c.AddSecurityDefinition(AuthenticationSchemes.CustomAuthentication, new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Name = "X-Emby-Token", + Description = "API key header parameter" + }); + + var securitySchemeRef = new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = AuthenticationSchemes.CustomAuthentication }, + }; + + // TODO: Apply this with an operation filter instead of globally + // https://github.com/domaindrivendev/Swashbuckle.AspNetCore#add-security-definitions-and-requirements + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { securitySchemeRef, Array.Empty() } + }); // Add all xml doc files to swagger generator. var xmlFiles = Directory.GetFiles( -- cgit v1.2.3 From 4fe0beec162d4554f1d6cc3c658b672eafbfa307 Mon Sep 17 00:00:00 2001 From: crobibero Date: Thu, 21 May 2020 08:44:15 -0600 Subject: Fix Json Enum conversion, map all JsonDefaults properties to API --- .../Extensions/ApiServiceCollectionExtensions.cs | 22 ++++++++---- .../Formatters/CamelCaseJsonProfileFormatter.cs | 4 +-- .../Formatters/PascalCaseJsonProfileFormatter.cs | 4 +-- Jellyfin.Server/Models/JsonOptions.cs | 41 ---------------------- MediaBrowser.Common/Json/JsonDefaults.cs | 34 +++++++++++++++++- 5 files changed, 53 insertions(+), 52 deletions(-) delete mode 100644 Jellyfin.Server/Models/JsonOptions.cs (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 344ef6a5f..b9f55e200 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -1,9 +1,6 @@ using System; -using System.Collections.Generic; using System.IO; -using System.Linq; using System.Reflection; -using System.Text.Json.Serialization; using Jellyfin.Api; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; @@ -11,6 +8,7 @@ using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; using Jellyfin.Server.Formatters; +using MediaBrowser.Common.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -83,8 +81,20 @@ namespace Jellyfin.Server.Extensions .AddApplicationPart(typeof(StartupController).Assembly) .AddJsonOptions(options => { - // Setting the naming policy to null leaves the property names as-is when serializing objects to JSON. - options.JsonSerializerOptions.PropertyNamingPolicy = null; + // Update all properties that are set in JsonDefaults + var jsonOptions = JsonDefaults.PascalCase; + + // From JsonDefaults + options.JsonSerializerOptions.ReadCommentHandling = jsonOptions.ReadCommentHandling; + options.JsonSerializerOptions.WriteIndented = jsonOptions.WriteIndented; + options.JsonSerializerOptions.Converters.Clear(); + foreach (var converter in jsonOptions.Converters) + { + options.JsonSerializerOptions.Converters.Add(converter); + } + + // From JsonDefaults.PascalCase + options.JsonSerializerOptions.PropertyNamingPolicy = jsonOptions.PropertyNamingPolicy; }) .AddControllersAsServices(); } @@ -98,7 +108,7 @@ namespace Jellyfin.Server.Extensions { return serviceCollection.AddSwaggerGen(c => { - c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API" }); + c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); // Add all xml doc files to swagger generator. var xmlFiles = Directory.GetFiles( diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs index e6ad6dfb1..989c8ecea 100644 --- a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs @@ -1,4 +1,4 @@ -using Jellyfin.Server.Models; +using MediaBrowser.Common.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; @@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters /// /// Initializes a new instance of the class. /// - public CamelCaseJsonProfileFormatter() : base(JsonOptions.CamelCase) + public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCase) { SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"CamelCase\"")); diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs index 675f4c79e..69963b3fb 100644 --- a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs @@ -1,4 +1,4 @@ -using Jellyfin.Server.Models; +using MediaBrowser.Common.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; @@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters /// /// Initializes a new instance of the class. /// - public PascalCaseJsonProfileFormatter() : base(JsonOptions.PascalCase) + public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCase) { SupportedMediaTypes.Clear(); // Add application/json for default formatter diff --git a/Jellyfin.Server/Models/JsonOptions.cs b/Jellyfin.Server/Models/JsonOptions.cs deleted file mode 100644 index 2f0df3d2c..000000000 --- a/Jellyfin.Server/Models/JsonOptions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Text.Json; - -namespace Jellyfin.Server.Models -{ - /// - /// Json Options. - /// - public static class JsonOptions - { - /// - /// Gets CamelCase json options. - /// - public static JsonSerializerOptions CamelCase - { - get - { - var options = DefaultJsonOptions; - options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - return options; - } - } - - /// - /// Gets PascalCase json options. - /// - public static JsonSerializerOptions PascalCase - { - get - { - var options = DefaultJsonOptions; - options.PropertyNamingPolicy = null; - return options; - } - } - - /// - /// Gets base Json Serializer Options. - /// - private static JsonSerializerOptions DefaultJsonOptions => new JsonSerializerOptions(); - } -} diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index 4a6ee0a79..326f04eea 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/MediaBrowser.Common/Json/JsonDefaults.cs @@ -12,10 +12,16 @@ namespace MediaBrowser.Common.Json /// /// Gets the default options. /// + /// + /// When changing these options, update + /// Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs + /// -> AddJellyfinApi + /// -> AddJsonOptions + /// /// The default options. public static JsonSerializerOptions GetOptions() { - var options = new JsonSerializerOptions() + var options = new JsonSerializerOptions { ReadCommentHandling = JsonCommentHandling.Disallow, WriteIndented = false @@ -26,5 +32,31 @@ namespace MediaBrowser.Common.Json return options; } + + /// + /// Gets CamelCase json options. + /// + public static JsonSerializerOptions CamelCase + { + get + { + var options = GetOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + return options; + } + } + + /// + /// Gets PascalCase json options. + /// + public static JsonSerializerOptions PascalCase + { + get + { + var options = GetOptions(); + options.PropertyNamingPolicy = null; + return options; + } + } } } -- cgit v1.2.3 From 01a5103fef83bbbef230faf2303d16648981a5d2 Mon Sep 17 00:00:00 2001 From: crobibero Date: Tue, 2 Jun 2020 11:47:00 -0600 Subject: Add Dictionary with non-string keys to System.Text.Json --- .../Extensions/ApiServiceCollectionExtensions.cs | 26 +++++++ .../JsonNonStringKeyDictionaryConverter.cs | 79 ++++++++++++++++++++++ .../JsonNonStringKeyDictionaryConverterFactory.cs | 60 ++++++++++++++++ MediaBrowser.Common/Json/JsonDefaults.cs | 1 + 4 files changed, 166 insertions(+) create mode 100644 MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs create mode 100644 MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index b9f55e200..cb4189587 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Reflection; using Jellyfin.Api; using Jellyfin.Api.Auth; @@ -9,6 +11,7 @@ using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; using Jellyfin.Server.Formatters; using MediaBrowser.Common.Json; +using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -128,7 +131,30 @@ namespace Jellyfin.Server.Extensions // Use method name as operationId c.CustomOperationIds(description => description.TryGetMethodInfo(out MethodInfo methodInfo) ? methodInfo.Name : null); + + // TODO - remove when all types are supported in System.Text.Json + c.AddSwaggerTypeMappings(); }); } + + private static void AddSwaggerTypeMappings(this SwaggerGenOptions options) + { + /* + * TODO remove when System.Text.Json supports non-string keys. + * Used in Jellyfin.Api.Controller.GetChannels. + */ + options.MapType>(() => + new OpenApiSchema + { + Type = "object", + Properties = typeof(ImageType).GetEnumNames().ToDictionary( + name => name, + name => new OpenApiSchema + { + Type = "string", + Format = "string" + }) + }); + } } } diff --git a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs new file mode 100644 index 000000000..f2ddd7fea --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs @@ -0,0 +1,79 @@ +#nullable enable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// + /// Converter for Dictionaries without string key. + /// TODO This can be removed when System.Text.Json supports Dictionaries with non-string keys. + /// + /// Type of key. + /// Type of value. + internal sealed class JsonNonStringKeyDictionaryConverter : JsonConverter> + { + /// + /// Read JSON. + /// + /// The Utf8JsonReader. + /// The type to convert. + /// The json serializer options. + /// Typed dictionary. + /// + public override IDictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var convertedType = typeof(Dictionary<,>).MakeGenericType(typeof(string), typeToConvert.GenericTypeArguments[1]); + var value = JsonSerializer.Deserialize(ref reader, convertedType, options); + var instance = (Dictionary)Activator.CreateInstance( + typeToConvert, + BindingFlags.Instance | BindingFlags.Public, + null, + null, + CultureInfo.CurrentCulture); + var enumerator = (IEnumerator)convertedType.GetMethod("GetEnumerator")!.Invoke(value, null); + var parse = typeof(TKey).GetMethod( + "Parse", + 0, + BindingFlags.Public | BindingFlags.Static, + null, + CallingConventions.Any, + new[] { typeof(string) }, + null); + if (parse == null) + { + throw new NotSupportedException($"{typeof(TKey)} as TKey in IDictionary is not supported."); + } + + while (enumerator.MoveNext()) + { + var element = (KeyValuePair)enumerator.Current; + instance.Add((TKey)parse.Invoke(null, new[] { (object?) element.Key }), element.Value); + } + + return instance; + } + + /// + /// Write dictionary as Json. + /// + /// The Utf8JsonWriter. + /// The dictionary value. + /// The Json serializer options. + public override void Write(Utf8JsonWriter writer, IDictionary value, JsonSerializerOptions options) + { + var convertedDictionary = new Dictionary(value.Count); + foreach (var (k, v) in value) + { + convertedDictionary[k?.ToString()] = v; + } + JsonSerializer.Serialize(writer, convertedDictionary, options); + convertedDictionary.Clear(); + } + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs new file mode 100644 index 000000000..d9795a189 --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs @@ -0,0 +1,60 @@ +#nullable enable + +using System; +using System.Collections; +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// + /// https://github.com/dotnet/runtime/issues/30524#issuecomment-524619972. + /// TODO This can be removed when System.Text.Json supports Dictionaries with non-string keys. + /// + internal sealed class JsonNonStringKeyDictionaryConverterFactory : JsonConverterFactory + { + /// + /// Only convert objects that implement IDictionary and do not have string keys. + /// + /// Type convert. + /// Conversion ability. + public override bool CanConvert(Type typeToConvert) + { + + if (!typeToConvert.IsGenericType) + { + return false; + } + + // Let built in converter handle string keys + if (typeToConvert.GenericTypeArguments[0] == typeof(string)) + { + return false; + } + + // Only support objects that implement IDictionary + return typeToConvert.GetInterface(nameof(IDictionary)) != null; + } + + /// + /// Create converter for generic dictionary type. + /// + /// Type to convert. + /// Json serializer options. + /// JsonConverter for given type. + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var converterType = typeof(JsonNonStringKeyDictionaryConverter<,>) + .MakeGenericType(typeToConvert.GenericTypeArguments[0], typeToConvert.GenericTypeArguments[1]); + var converter = (JsonConverter)Activator.CreateInstance( + converterType, + BindingFlags.Instance | BindingFlags.Public, + null, + null, + CultureInfo.CurrentCulture); + return converter; + } + } +} diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index 326f04eea..f38e2893e 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/MediaBrowser.Common/Json/JsonDefaults.cs @@ -29,6 +29,7 @@ namespace MediaBrowser.Common.Json options.Converters.Add(new JsonGuidConverter()); options.Converters.Add(new JsonStringEnumConverter()); + options.Converters.Add(new JsonNonStringKeyDictionaryConverterFactory()); return options; } -- cgit v1.2.3 From 598cd94c4d5fd8911c9e30af7be47ca6eac7c975 Mon Sep 17 00:00:00 2001 From: crobibero Date: Sat, 6 Jun 2020 16:51:21 -0600 Subject: Add CSS output formatter. --- .../Extensions/ApiServiceCollectionExtensions.cs | 2 ++ Jellyfin.Server/Formatters/CssOutputFormatter.cs | 37 ++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 Jellyfin.Server/Formatters/CssOutputFormatter.cs (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index cb4189587..1c7fcbc06 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -77,6 +77,8 @@ namespace Jellyfin.Server.Extensions opts.UseGeneralRoutePrefix(baseUrl); opts.OutputFormatters.Insert(0, new CamelCaseJsonProfileFormatter()); opts.OutputFormatters.Insert(0, new PascalCaseJsonProfileFormatter()); + + opts.OutputFormatters.Add(new CssOutputFormatter()); }) // Clear app parts to avoid other assemblies being picked up diff --git a/Jellyfin.Server/Formatters/CssOutputFormatter.cs b/Jellyfin.Server/Formatters/CssOutputFormatter.cs new file mode 100644 index 000000000..b26e7f96a --- /dev/null +++ b/Jellyfin.Server/Formatters/CssOutputFormatter.cs @@ -0,0 +1,37 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Jellyfin.Server.Formatters +{ + /// + /// Css output formatter. + /// + public class CssOutputFormatter : TextOutputFormatter + { + /// + /// Initializes a new instance of the class. + /// + public CssOutputFormatter() + { + SupportedMediaTypes.Clear(); + SupportedMediaTypes.Add("text/css"); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + /// + /// Write context object to stream. + /// + /// Writer context. + /// Unused. Writer encoding. + /// Write stream task. + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + return context.HttpContext.Response.WriteAsync(context.Object?.ToString()); + } + } +} \ No newline at end of file -- cgit v1.2.3 From 720fff30a4da7490ce2ce6053cb496dbf19d6c8f Mon Sep 17 00:00:00 2001 From: crobibero Date: Fri, 12 Jun 2020 14:37:55 -0600 Subject: readd swagger version --- Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 009746243..c3c8716c0 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -119,7 +119,7 @@ namespace Jellyfin.Server.Extensions { return serviceCollection.AddSwaggerGen(c => { - c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API" }); + c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); c.AddSecurityDefinition(AuthenticationSchemes.CustomAuthentication, new OpenApiSecurityScheme { Type = SecuritySchemeType.ApiKey, -- cgit v1.2.3 From 6d5c09c4990ecbc071afbe6611ecef75e5fe8b65 Mon Sep 17 00:00:00 2001 From: crobibero Date: Fri, 12 Jun 2020 14:40:06 -0600 Subject: Remove duplicate swaggerdoc --- Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs | 1 - 1 file changed, 1 deletion(-) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index c3c8716c0..86d547af0 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -139,7 +139,6 @@ namespace Jellyfin.Server.Extensions { { securitySchemeRef, Array.Empty() } }); - c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); // Add all xml doc files to swagger generator. var xmlFiles = Directory.GetFiles( -- cgit v1.2.3 From ec3e15db5789b6218482beb488433f41f9a0d8ba Mon Sep 17 00:00:00 2001 From: crobibero Date: Sat, 13 Jun 2020 13:11:41 -0600 Subject: Fix merge and build --- Jellyfin.Api/BaseJellyfinApiController.cs | 4 -- Jellyfin.Api/Controllers/ActivityLogController.cs | 11 +++- .../ConfigurationDtos/MediaEncoderPathDto.cs | 6 +- .../Extensions/ApiServiceCollectionExtensions.cs | 2 +- .../Formatters/CamelCaseJsonProfileFormatter.cs | 2 +- .../Formatters/PascalCaseJsonProfileFormatter.cs | 2 +- MediaBrowser.Api/MediaBrowser.Api.csproj | 4 -- MediaBrowser.Api/System/ActivityLogService.cs | 66 ---------------------- .../JsonNonStringKeyDictionaryConverter.cs | 26 +++++---- .../JsonNonStringKeyDictionaryConverterFactory.cs | 5 +- MediaBrowser.Common/Json/JsonDefaults.cs | 30 +++++----- 11 files changed, 46 insertions(+), 112 deletions(-) delete mode 100644 MediaBrowser.Api/System/ActivityLogService.cs (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Api/BaseJellyfinApiController.cs b/Jellyfin.Api/BaseJellyfinApiController.cs index 615f330a4..a34f9eb62 100644 --- a/Jellyfin.Api/BaseJellyfinApiController.cs +++ b/Jellyfin.Api/BaseJellyfinApiController.cs @@ -1,8 +1,4 @@ -<<<<<<< HEAD -using System; -======= using System.Net.Mime; ->>>>>>> origin/master using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Api diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index 8d37a8373..895d9f719 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -1,6 +1,10 @@ +#nullable enable +#pragma warning disable CA1801 + using System; -using System.Globalization; +using System.Linq; using Jellyfin.Api.Constants; +using Jellyfin.Data.Entities; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; @@ -44,7 +48,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] DateTime? minDate, bool? hasUserId) { - return _activityManager.GetActivityLogEntries(minDate, hasUserId, startIndex, limit); + var filterFunc = new Func, IQueryable>( + entries => entries.Where(entry => entry.DateCreated >= minDate)); + + return _activityManager.GetPagedResult(filterFunc, startIndex, limit); } } } diff --git a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs index b05e0cdf5..3706a11e3 100644 --- a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs +++ b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs @@ -1,3 +1,5 @@ +#nullable enable + namespace Jellyfin.Api.Models.ConfigurationDtos { /// @@ -8,11 +10,11 @@ namespace Jellyfin.Api.Models.ConfigurationDtos /// /// Gets or sets media encoder path. /// - public string Path { get; set; } + public string Path { get; set; } = null!; /// /// Gets or sets media encoder path type. /// - public string PathType { get; set; } + public string PathType { get; set; } = null!; } } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 86d547af0..9cdaa0eb1 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -93,7 +93,7 @@ namespace Jellyfin.Server.Extensions .AddJsonOptions(options => { // Update all properties that are set in JsonDefaults - var jsonOptions = JsonDefaults.PascalCase; + var jsonOptions = JsonDefaults.GetPascalCaseOptions(); // From JsonDefaults options.JsonSerializerOptions.ReadCommentHandling = jsonOptions.ReadCommentHandling; diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs index 989c8ecea..9b347ae2c 100644 --- a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs @@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters /// /// Initializes a new instance of the class. /// - public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCase) + public CamelCaseJsonProfileFormatter() : base(JsonDefaults.GetCamelCaseOptions()) { SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"CamelCase\"")); diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs index 69963b3fb..0024708ba 100644 --- a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs @@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters /// /// Initializes a new instance of the class. /// - public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCase) + public PascalCaseJsonProfileFormatter() : base(JsonDefaults.GetPascalCaseOptions()) { SupportedMediaTypes.Clear(); // Add application/json for default formatter diff --git a/MediaBrowser.Api/MediaBrowser.Api.csproj b/MediaBrowser.Api/MediaBrowser.Api.csproj index 0b0c5cc9f..d703bdb05 100644 --- a/MediaBrowser.Api/MediaBrowser.Api.csproj +++ b/MediaBrowser.Api/MediaBrowser.Api.csproj @@ -14,10 +14,6 @@ - - - - netstandard2.1 false diff --git a/MediaBrowser.Api/System/ActivityLogService.cs b/MediaBrowser.Api/System/ActivityLogService.cs deleted file mode 100644 index a6bacad4f..000000000 --- a/MediaBrowser.Api/System/ActivityLogService.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using Jellyfin.Data.Entities; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Activity; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.System -{ - [Route("/System/ActivityLog/Entries", "GET", Summary = "Gets activity log entries")] - public class GetActivityLogs : IReturn> - { - /// - /// Skips over a given number of items within the results. Use for paging. - /// - /// The start index. - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// - /// The maximum number of items to return - /// - /// The limit. - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "MinDate", Description = "Optional. The minimum date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string MinDate { get; set; } - - public bool? HasUserId { get; set; } - } - - [Authenticated(Roles = "Admin")] - public class ActivityLogService : BaseApiService - { - private readonly IActivityManager _activityManager; - - public ActivityLogService( - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IActivityManager activityManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _activityManager = activityManager; - } - - public object Get(GetActivityLogs request) - { - DateTime? minDate = string.IsNullOrWhiteSpace(request.MinDate) ? - (DateTime?)null : - DateTime.Parse(request.MinDate, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - - var filterFunc = new Func, IQueryable>( - entries => entries.Where(entry => entry.DateCreated >= minDate)); - - var result = _activityManager.GetPagedResult(filterFunc, request.StartIndex, request.Limit); - - return ToOptimizedResult(result); - } - } -} diff --git a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs index 636ef5372..8053461f0 100644 --- a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs +++ b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs @@ -25,7 +25,7 @@ namespace MediaBrowser.Common.Json.Converters /// The type to convert. /// The json serializer options. /// Typed dictionary. - /// + /// Dictionary key type not supported. public override IDictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { var convertedType = typeof(Dictionary<,>).MakeGenericType(typeof(string), typeToConvert.GenericTypeArguments[1]); @@ -38,24 +38,24 @@ namespace MediaBrowser.Common.Json.Converters CultureInfo.CurrentCulture); var enumerator = (IEnumerator)convertedType.GetMethod("GetEnumerator")!.Invoke(value, null); var parse = typeof(TKey).GetMethod( - "Parse", - 0, - BindingFlags.Public | BindingFlags.Static, - null, - CallingConventions.Any, - new[] { typeof(string) }, + "Parse", + 0, + BindingFlags.Public | BindingFlags.Static, + null, + CallingConventions.Any, + new[] { typeof(string) }, null); if (parse == null) { throw new NotSupportedException($"{typeof(TKey)} as TKey in IDictionary is not supported."); } - + while (enumerator.MoveNext()) { var element = (KeyValuePair)enumerator.Current; - instance.Add((TKey)parse.Invoke(null, new[] { (object?) element.Key }), element.Value); + instance.Add((TKey)parse.Invoke(null, new[] { (object?)element.Key }), element.Value); } - + return instance; } @@ -70,8 +70,12 @@ namespace MediaBrowser.Common.Json.Converters var convertedDictionary = new Dictionary(value.Count); foreach (var (k, v) in value) { - convertedDictionary[k?.ToString()] = v; + if (k != null) + { + convertedDictionary[k.ToString()] = v; + } } + JsonSerializer.Serialize(writer, convertedDictionary, options); } } diff --git a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs index d9795a189..52f360740 100644 --- a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs +++ b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs @@ -22,18 +22,17 @@ namespace MediaBrowser.Common.Json.Converters /// Conversion ability. public override bool CanConvert(Type typeToConvert) { - if (!typeToConvert.IsGenericType) { return false; } - + // Let built in converter handle string keys if (typeToConvert.GenericTypeArguments[0] == typeof(string)) { return false; } - + // Only support objects that implement IDictionary return typeToConvert.GetInterface(nameof(IDictionary)) != null; } diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index f38e2893e..adc15123b 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/MediaBrowser.Common/Json/JsonDefaults.cs @@ -16,7 +16,7 @@ namespace MediaBrowser.Common.Json /// When changing these options, update /// Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs /// -> AddJellyfinApi - /// -> AddJsonOptions + /// -> AddJsonOptions. /// /// The default options. public static JsonSerializerOptions GetOptions() @@ -33,31 +33,27 @@ namespace MediaBrowser.Common.Json return options; } - + /// - /// Gets CamelCase json options. + /// Gets camelCase json options. /// - public static JsonSerializerOptions CamelCase + /// The camelCase options. + public static JsonSerializerOptions GetCamelCaseOptions() { - get - { - var options = GetOptions(); - options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - return options; - } + var options = GetOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + return options; } /// /// Gets PascalCase json options. /// - public static JsonSerializerOptions PascalCase + /// The PascalCase options. + public static JsonSerializerOptions GetPascalCaseOptions() { - get - { - var options = GetOptions(); - options.PropertyNamingPolicy = null; - return options; - } + var options = GetOptions(); + options.PropertyNamingPolicy = null; + return options; } } } -- 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 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') 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 0c01b6817b9e14661fd1ebea05590b60278e735c Mon Sep 17 00:00:00 2001 From: crobibero Date: Wed, 17 Jun 2020 08:05:30 -0600 Subject: Add X-Forward-(For/Proto) support --- Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 1ec77d716..dbd5ba416 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -18,6 +18,8 @@ using MediaBrowser.Common.Json; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; @@ -105,6 +107,10 @@ namespace Jellyfin.Server.Extensions { options.AddPolicy(ServerCorsPolicy.DefaultPolicyName, ServerCorsPolicy.DefaultPolicy); }) + .Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + }) .AddMvc(opts => { opts.UseGeneralRoutePrefix(baseUrl); -- cgit v1.2.3 From 6651cb8d24f0de690b3be68db7c0b78e2413534f Mon Sep 17 00:00:00 2001 From: David Date: Fri, 19 Jun 2020 12:24:39 +0200 Subject: Add JsonInto32Converter Add additional swagger type mapping --- .../Extensions/ApiServiceCollectionExtensions.cs | 13 ++++++++ .../Json/Converters/JsonInt32Converter.cs | 37 +++++++--------------- MediaBrowser.Common/Json/JsonDefaults.cs | 1 + 3 files changed, 26 insertions(+), 25 deletions(-) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index dbd5ba416..821a52e47 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -215,6 +215,19 @@ namespace Jellyfin.Server.Extensions Format = "string" }) }); + + options.MapType>>(() => + new OpenApiSchema + { + Type = "object", + Properties = typeof(ImageType).GetEnumNames().ToDictionary( + name => name, + name => new OpenApiSchema + { + Type = "string", + Format = "string" + }) + }); } } } diff --git a/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs b/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs index fe5dd6cd4..70c375b8c 100644 --- a/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs +++ b/MediaBrowser.Common/Json/Converters/JsonInt32Converter.cs @@ -14,40 +14,27 @@ namespace MediaBrowser.Common.Json.Converters /// public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - static void ThrowFormatException() => throw new FormatException("Invalid format for an integer."); - ReadOnlySpan span = stackalloc byte[0]; - - if (reader.HasValueSequence) - { - long sequenceLength = reader.ValueSequence.Length; - Span stackSpan = stackalloc byte[(int)sequenceLength]; - reader.ValueSequence.CopyTo(stackSpan); - span = stackSpan; - } - else + if (reader.TokenType == JsonTokenType.String) { - span = reader.ValueSpan; - } + ReadOnlySpan span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan; + if (Utf8Parser.TryParse(span, out int number, out int bytesConsumed) && span.Length == bytesConsumed) + { + return number; + } - if (!Utf8Parser.TryParse(span, out int number, out _)) - { - ThrowFormatException(); + if (int.TryParse(reader.GetString(), out number)) + { + return number; + } } - return number; + return reader.GetInt32(); } /// public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) { - static void ThrowInvalidOperationException() => throw new InvalidOperationException(); - Span span = stackalloc byte[16]; - if (Utf8Formatter.TryFormat(value, span, out int bytesWritten)) - { - writer.WriteStringValue(span.Slice(0, bytesWritten)); - } - - ThrowInvalidOperationException(); + writer.WriteNumberValue(value); } } } diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index adc15123b..ec3c45476 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/MediaBrowser.Common/Json/JsonDefaults.cs @@ -28,6 +28,7 @@ namespace MediaBrowser.Common.Json }; options.Converters.Add(new JsonGuidConverter()); + options.Converters.Add(new JsonInt32Converter()); options.Converters.Add(new JsonStringEnumConverter()); options.Converters.Add(new JsonNonStringKeyDictionaryConverterFactory()); -- cgit v1.2.3 From 08401f923dbe1bdf0086f905a6a9575b5ac9c5cd Mon Sep 17 00:00:00 2001 From: David Date: Fri, 19 Jun 2020 15:49:44 +0200 Subject: Change swagger dictionary type mapping --- .../Extensions/ApiServiceCollectionExtensions.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 821a52e47..aad61d042 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -216,6 +216,9 @@ namespace Jellyfin.Server.Extensions }) }); + /* + * Support BlurHash dictionary + */ options.MapType>>(() => new OpenApiSchema { @@ -224,8 +227,17 @@ namespace Jellyfin.Server.Extensions name => name, name => new OpenApiSchema { - Type = "string", - Format = "string" + Type = "object", Properties = new Dictionary + { + { + "string", + new OpenApiSchema + { + Type = "string", + Format = "string" + } + } + } }) }); } -- cgit v1.2.3 From e2a7e8d97e26059d034e7c338adc0eb191642d80 Mon Sep 17 00:00:00 2001 From: crobibero Date: Fri, 19 Jun 2020 13:10:10 -0600 Subject: Move LibraryService.cs to Jellyfin.Api --- Jellyfin.Api/Auth/BaseAuthorizationHandler.cs | 11 +- .../Auth/DownloadPolicy/DownloadHandler.cs | 45 + .../Auth/DownloadPolicy/DownloadRequirement.cs | 11 + Jellyfin.Api/Constants/Policies.cs | 5 + Jellyfin.Api/Controllers/LibraryController.cs | 965 ++++++++++++++++- Jellyfin.Api/Helpers/RequestHelpers.cs | 18 + .../Models/LibraryDtos/LibraryOptionInfoDto.cs | 18 + .../Models/LibraryDtos/LibraryOptionsResultDto.cs | 34 + .../Models/LibraryDtos/LibraryTypeOptionsDto.cs | 41 + .../Models/LibraryDtos/MediaUpdateInfoDto.cs | 19 + .../Extensions/ApiServiceCollectionExtensions.cs | 9 + MediaBrowser.Api/Library/LibraryService.cs | 1116 -------------------- MediaBrowser.Api/MediaBrowser.Api.csproj | 4 + 13 files changed, 1178 insertions(+), 1118 deletions(-) create mode 100644 Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs create mode 100644 Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs create mode 100644 Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs create mode 100644 Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs create mode 100644 Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs create mode 100644 Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs delete mode 100644 MediaBrowser.Api/Library/LibraryService.cs (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs index b5b9d8904..c66b841fa 100644 --- a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs @@ -44,11 +44,13 @@ namespace Jellyfin.Api.Auth /// Request claims. /// Whether to ignore parental control. /// Whether access is to be allowed locally only. + /// Whether validation requires download permission. /// Validated claim status. protected bool ValidateClaims( ClaimsPrincipal claimsPrincipal, bool ignoreSchedule = false, - bool localAccessOnly = false) + bool localAccessOnly = false, + bool requiredDownloadPermission = false) { // Ensure claim has userId. var userId = ClaimHelpers.GetUserId(claimsPrincipal); @@ -91,6 +93,13 @@ namespace Jellyfin.Api.Auth return false; } + // User attempting to download without permission. + if (requiredDownloadPermission + && !user.HasPermission(PermissionKind.EnableContentDownloading)) + { + return false; + } + return true; } diff --git a/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs b/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs new file mode 100644 index 000000000..fcfa55dfe --- /dev/null +++ b/Jellyfin.Api/Auth/DownloadPolicy/DownloadHandler.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.DownloadPolicy +{ + /// + /// Download authorization handler. + /// + public class DownloadHandler : BaseAuthorizationHandler + { + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public DownloadHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + } + + /// + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, DownloadRequirement requirement) + { + var validated = ValidateClaims(context.User); + if (validated) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs b/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs new file mode 100644 index 000000000..b0a72a9de --- /dev/null +++ b/Jellyfin.Api/Auth/DownloadPolicy/DownloadRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.DownloadPolicy +{ + /// + /// The download permission requirement. + /// + public class DownloadRequirement : IAuthorizationRequirement + { + } +} diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index cf574e43d..851b56d73 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -29,5 +29,10 @@ namespace Jellyfin.Api.Constants /// Policy name for escaping schedule controls. /// public const string IgnoreSchedule = "IgnoreSchedule"; + + /// + /// Policy name for requiring download permission. + /// + public const string Download = "Download"; } } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index f45101c0c..e3e2e9489 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -1,10 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.LibraryDtos; +using Jellyfin.Data.Entities; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Activity; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; +using Book = MediaBrowser.Controller.Entities.Book; +using Movie = Jellyfin.Data.Entities.Movie; +using MusicAlbum = Jellyfin.Data.Entities.MusicAlbum; namespace Jellyfin.Api.Controllers { @@ -21,6 +52,8 @@ namespace Jellyfin.Api.Controllers private readonly IActivityManager _activityManager; private readonly ILocalizationManager _localization; private readonly ILibraryMonitor _libraryMonitor; + private readonly ILogger _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; /// /// Initializes a new instance of the class. @@ -33,6 +66,8 @@ namespace Jellyfin.Api.Controllers /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. public LibraryController( IProviderManager providerManager, ILibraryManager libraryManager, @@ -41,7 +76,9 @@ namespace Jellyfin.Api.Controllers IAuthorizationContext authContext, IActivityManager activityManager, ILocalizationManager localization, - ILibraryMonitor libraryMonitor) + ILibraryMonitor libraryMonitor, + ILogger logger, + IServerConfigurationManager serverConfigurationManager) { _providerManager = providerManager; _libraryManager = libraryManager; @@ -51,6 +88,932 @@ namespace Jellyfin.Api.Controllers _activityManager = activityManager; _localization = localization; _libraryMonitor = libraryMonitor; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + } + + /// + /// Get the original file of an item. + /// + /// The item id. + /// File stream returned. + /// Item not found. + /// A with the original file. + [HttpGet("/Items/{itemId}/File")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult GetFile([FromRoute] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + using var fileStream = new FileStream(item.Path, FileMode.Open, FileAccess.Read); + return File(fileStream, MimeTypes.GetMimeType(item.Path)); + } + + /// + /// Gets critic review for an item. + /// + /// The item id. + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Critic reviews returned. + /// The list of critic reviews. + [HttpGet("/Items/{itemId}/CriticReviews")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [Obsolete("This endpoint is obsolete.")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")] + public ActionResult> GetCriticReviews( + [FromRoute] Guid itemId, + [FromQuery] int? startIndex, + [FromQuery] int? limit) + { + return new QueryResult(); + } + + /// + /// Get theme songs for an item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. Determines whether or not parent items should be searched for theme media. + /// Theme songs returned. + /// Item not found. + /// The item theme songs. + [HttpGet("/Items/{itemId}/ThemeSongs")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult GetThemeSongs( + [FromRoute] Guid itemId, + [FromQuery] Guid userId, + [FromQuery] bool inheritFromParent) + { + var user = !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId) + : null; + + var item = itemId.Equals(Guid.Empty) + ? (!userId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.RootFolder) + : _libraryManager.GetItemById(itemId); + + if (item == null) + { + return NotFound("Item not found."); + } + + IEnumerable themeItems; + + while (true) + { + themeItems = item.GetThemeSongs(); + + if (themeItems.Any() || !inheritFromParent) + { + break; + } + + var parent = item.GetParent(); + if (parent == null) + { + break; + } + + item = parent; + } + + var dtoOptions = new DtoOptions().AddClientFields(Request); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); + + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } + + /// + /// Get theme videos for an item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. Determines whether or not parent items should be searched for theme media. + /// Theme videos returned. + /// Item not found. + /// The item theme videos. + [HttpGet("/Items/{itemId}/ThemeVideos")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult GetThemeVideos( + [FromRoute] Guid itemId, + [FromQuery] Guid userId, + [FromQuery] bool inheritFromParent) + { + var user = !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId) + : null; + + var item = itemId.Equals(Guid.Empty) + ? (!userId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.RootFolder) + : _libraryManager.GetItemById(itemId); + + if (item == null) + { + return NotFound("Item not found."); + } + + IEnumerable themeItems; + + while (true) + { + themeItems = item.GetThemeVideos(); + + if (themeItems.Any() || !inheritFromParent) + { + break; + } + + var parent = item.GetParent(); + if (parent == null) + { + break; + } + + item = parent; + } + + var dtoOptions = new DtoOptions().AddClientFields(Request); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); + + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } + + /// + /// Get theme songs and videos for an item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Optional. Determines whether or not parent items should be searched for theme media. + /// Theme songs and videos returned. + /// Item not found. + /// The item theme videos. + public ActionResult GetThemeMedia( + [FromRoute] Guid itemId, + [FromQuery] Guid userId, + [FromQuery] bool inheritFromParent) + { + var themeSongs = GetThemeSongs( + itemId, + userId, + inheritFromParent); + + var themeVideos = GetThemeVideos( + itemId, + userId, + inheritFromParent); + + return new AllThemeMediaResult + { + ThemeSongsResult = themeSongs?.Value, + ThemeVideosResult = themeVideos?.Value, + SoundtrackSongsResult = new ThemeMediaResult() + }; + } + + /// + /// Starts a library scan. + /// + /// Library scan started. + /// A . + [HttpGet("/Library/Refresh")] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task RefreshLibrary() + { + try + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing library"); + } + + return NoContent(); + } + + /// + /// Deletes an item from the library and filesystem. + /// + /// The item id. + /// Item deleted. + /// Unauthorized access. + /// A . + [HttpDelete("/Items/{itemId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult DeleteItem(Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + var auth = _authContext.GetAuthorizationInfo(Request); + var user = auth.User; + + if (!item.CanDelete(user)) + { + return Unauthorized("Unauthorized access"); + } + + _libraryManager.DeleteItem( + item, + new DeleteOptions { DeleteFileLocation = true }, + true); + + return NoContent(); + } + + /// + /// Deletes items from the library and filesystem. + /// + /// The item ids. + /// Items deleted. + /// Unauthorized access. + /// A . + [HttpDelete("/Items")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult DeleteItems([FromQuery] string ids) + { + var itemIds = string.IsNullOrWhiteSpace(ids) + ? Array.Empty() + : RequestHelpers.Split(ids, ',', true); + + foreach (var i in itemIds) + { + var item = _libraryManager.GetItemById(i); + var auth = _authContext.GetAuthorizationInfo(Request); + var user = auth.User; + + if (!item.CanDelete(user)) + { + if (ids.Length > 1) + { + return Unauthorized("Unauthorized access"); + } + + continue; + } + + _libraryManager.DeleteItem( + item, + new DeleteOptions { DeleteFileLocation = true }, + true); + } + + return NoContent(); + } + + /// + /// Get item counts. + /// + /// Optional. Get counts from a specific user's library. + /// Optional. Get counts of favorite items. + /// Item counts returned. + /// Item counts. + [HttpGet("/Items/Counts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult GetItemCounts( + [FromQuery] Guid userId, + [FromQuery] bool? isFavorite) + { + var user = userId.Equals(Guid.Empty) + ? null + : _userManager.GetUserById(userId); + + var counts = new ItemCounts + { + AlbumCount = GetCount(typeof(MusicAlbum), user, isFavorite), + EpisodeCount = GetCount(typeof(Episode), user, isFavorite), + MovieCount = GetCount(typeof(Movie), user, isFavorite), + SeriesCount = GetCount(typeof(Series), user, isFavorite), + SongCount = GetCount(typeof(Audio), user, isFavorite), + MusicVideoCount = GetCount(typeof(MusicVideo), user, isFavorite), + BoxSetCount = GetCount(typeof(BoxSet), user, isFavorite), + BookCount = GetCount(typeof(Book), user, isFavorite) + }; + + return counts; + } + + /// + /// Gets all parents of an item. + /// + /// The item id. + /// Optional. Filter by user id, and attach user data. + /// Item parents returned. + /// Item not found. + /// Item parents. + [HttpGet("/Items/{itemId}/Ancestors")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult> GetAncestors([FromRoute] Guid itemId, [FromQuery] Guid userId) + { + var item = _libraryManager.GetItemById(itemId); + + if (item == null) + { + return NotFound("Item not found"); + } + + var baseItemDtos = new List(); + + var user = !userId.Equals(Guid.Empty) + ? _userManager.GetUserById(userId) + : null; + + var dtoOptions = new DtoOptions().AddClientFields(Request); + BaseItem parent = item.GetParent(); + + while (parent != null) + { + if (user != null) + { + parent = TranslateParentItem(parent, user); + } + + baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); + + parent = parent.GetParent(); + } + + return baseItemDtos; + } + + /// + /// Gets a list of physical paths from virtual folders. + /// + /// Physical paths returned. + /// List of physical paths. + [HttpGet("/Library/PhysicalPaths")] + [Authorize(Policy = Policies.RequiresElevation)] + public ActionResult> GetPhysicalPaths() + { + return Ok(_libraryManager.RootFolder.Children + .SelectMany(c => c.PhysicalLocations)); + } + + /// + /// Gets all user media folders. + /// + /// Optional. Filter by folders that are marked hidden, or not. + /// List of user media folders. + [HttpGet("/Library/MediaFolders")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult> GetMediaFolders([FromQuery] bool? isHidden) + { + var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList(); + + if (isHidden.HasValue) + { + var val = isHidden.Value; + + items = items.Where(i => i.IsHidden == val).ToList(); + } + + var dtoOptions = new DtoOptions().AddClientFields(Request); + var result = new QueryResult + { + TotalRecordCount = items.Count, + Items = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions)).ToArray() + }; + + return result; + } + + /// + /// Reports that new episodes of a series have been added by an external source. + /// + /// The tvdbId. + /// Report success. + /// A . + [HttpPost("/Library/Series/Added")] + [HttpPost("/Library/Series/Updated")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult PostUpdatedSeries([FromQuery] string tvdbId) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { nameof(Series) }, + DtoOptions = new DtoOptions(false) + { + EnableImages = false + } + }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray(); + + foreach (var item in series) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); + } + + return NoContent(); + } + + /// + /// Reports that new movies have been added by an external source. + /// + /// The tmdbId. + /// The imdbId. + /// Report success. + /// A . + [HttpPost("/Library/Movies/Added")] + [HttpPost("/Library/Movies/Updated")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult PostUpdatedMovies([FromRoute] string tmdbId, [FromRoute] string imdbId) + { + var movies = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { nameof(Movie) }, + DtoOptions = new DtoOptions(false) + { + EnableImages = false + } + }); + + if (!string.IsNullOrWhiteSpace(imdbId)) + { + movies = movies.Where(i => string.Equals(imdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList(); + } + else if (!string.IsNullOrWhiteSpace(tmdbId)) + { + movies = movies.Where(i => string.Equals(tmdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList(); + } + else + { + movies = new List(); + } + + foreach (var item in movies) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); + } + + return NoContent(); + } + + /// + /// Reports that new movies have been added by an external source. + /// + /// A list of updated media paths. + /// Report success. + /// A . + [HttpPost("/Library/Media/Updated")] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult PostUpdatedMedia([FromBody, BindRequired] MediaUpdateInfoDto[] updates) + { + foreach (var item in updates) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); + } + + return NoContent(); + } + + /// + /// Downloads item media. + /// + /// The item id. + /// Media downloaded. + /// Item not found. + /// A containing the media stream. + /// User can't download or item can't be downloaded. + [HttpGet("/Items/{itemId}/Download")] + [Authorize(Policy = Policies.Download)] + public ActionResult GetDownload([FromRoute] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + var auth = _authContext.GetAuthorizationInfo(Request); + + var user = auth.User; + + if (user != null) + { + if (!item.CanDownload(user)) + { + throw new ArgumentException("Item does not support downloading"); + } + } + else + { + if (!item.CanDownload()) + { + throw new ArgumentException("Item does not support downloading"); + } + } + + if (user != null) + { + LogDownload(item, user, auth); + } + + var path = item.Path; + + // Quotes are valid in linux. They'll possibly cause issues here + var filename = (Path.GetFileName(path) ?? string.Empty).Replace("\"", string.Empty, StringComparison.Ordinal); + if (!string.IsNullOrWhiteSpace(filename)) + { + // Kestrel doesn't support non-ASCII characters in headers + if (Regex.IsMatch(filename, @"[^\p{IsBasicLatin}]")) + { + // Manually encoding non-ASCII characters, following https://tools.ietf.org/html/rfc5987#section-3.2.2 + filename = WebUtility.UrlEncode(filename); + } + } + + // TODO determine non-ASCII validity. + using var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read); + return File(fileStream, MimeTypes.GetMimeType(path), filename); + } + + /// + /// Gets similar items. + /// + /// The item id. + /// Exclude artist ids. + /// (Unused) Optional. include image information in output. + /// (Unused) Optional. include user data. + /// (Unused) Optional. the max number of images to return, per image type. + /// (Unused) Optional. The image types to include in the output. + /// Optional. Filter by user id, and attach user data. + /// Optional. The maximum number of records to return. + /// Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls. + /// A containing the similar items. + [HttpGet("/Artists/{itemId}/Similar")] + [HttpGet("/Items/{itemId}/Similar")] + [HttpGet("/Albums/{itemId}/Similar")] + [HttpGet("/Shows/{itemId}/Similar")] + [HttpGet("/Movies/{itemId}/Similar")] + [HttpGet("/Trailers/{itemId}/Similar")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")] + public ActionResult> GetSimilarItems( + [FromRoute] Guid itemId, + [FromQuery] string excludeArtistIds, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery] string enableImageTypes, + [FromQuery] Guid userId, + [FromQuery] int? limit, + [FromQuery] string fields) + { + var item = itemId.Equals(Guid.Empty) + ? (!userId.Equals(Guid.Empty) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.RootFolder) + : _libraryManager.GetItemById(itemId); + + var program = item as IHasProgramAttributes; + if (item is MediaBrowser.Controller.Entities.Movies.Movie || (program != null && program.IsMovie) || item is Trailer) + { + /* + * // TODO + return new MoviesService( + _moviesServiceLogger, + ServerConfigurationManager, + ResultFactory, + _userManager, + _libraryManager, + _dtoService, + _authContext) + { + Request = Request, + }.GetSimilarItemsResult(request);*/ + } + + if (program != null && program.IsSeries) + { + return GetSimilarItemsResult( + item, + excludeArtistIds, + userId, + limit, + fields, + new[] { nameof(Series) }); + } + + if (item is MediaBrowser.Controller.Entities.TV.Episode || (item is IItemByName && !(item is MusicArtist))) + { + return new QueryResult(); + } + + return GetSimilarItemsResult( + item, + excludeArtistIds, + userId, + limit, + fields, + new[] { item.GetType().Name }); + } + + /// + /// Gets the library options info. + /// + /// Library content type. + /// Whether this is a new library. + /// Library options info returned. + /// Library options info. + [HttpGet("/Libraries/AvailableOptions")] + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] + public ActionResult GetLibraryOptionsInfo([FromQuery] string libraryContentType, [FromQuery] bool isNewLibrary) + { + var result = new LibraryOptionsResultDto(); + + var types = GetRepresentativeItemTypes(libraryContentType); + var typesList = types.ToList(); + + var plugins = _providerManager.GetAllMetadataPlugins() + .Where(i => types.Contains(i.ItemType, StringComparer.OrdinalIgnoreCase)) + .OrderBy(i => typesList.IndexOf(i.ItemType)) + .ToList(); + + result.MetadataSavers = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary) + }) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(); + + result.MetadataReaders = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = true + }) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(); + + result.SubtitleFetchers = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = true + }) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(); + + var typeOptions = new List(); + + foreach (var type in types) + { + TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions); + + typeOptions.Add(new LibraryTypeOptionsDto + { + Type = type, + + MetadataFetchers = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = IsMetadataFetcherEnabledByDefault(i.Name, type, isNewLibrary) + }) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(), + + ImageFetchers = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = IsImageFetcherEnabledByDefault(i.Name, type, isNewLibrary) + }) + .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => x.First()) + .ToArray(), + + SupportedImageTypes = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.SupportedImageTypes ?? Array.Empty()) + .Distinct() + .ToArray(), + + DefaultImageOptions = defaultImageOptions ?? Array.Empty() + }); + } + + result.TypeOptions = typeOptions.ToArray(); + + return result; + } + + private int GetCount(Type type, User? user, bool? isFavorite) + { + var query = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { type.Name }, + Limit = 0, + Recursive = true, + IsVirtualItem = false, + IsFavorite = isFavorite, + DtoOptions = new DtoOptions(false) + { + EnableImages = false + } + }; + + return _libraryManager.GetItemsResult(query).TotalRecordCount; + } + + private BaseItem TranslateParentItem(BaseItem item, User user) + { + return item.GetParent() is AggregateFolder + ? _libraryManager.GetUserRootFolder().GetChildren(user, true) + .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path)) + : item; + } + + private void LogDownload(BaseItem item, User user, AuthorizationInfo auth) + { + try + { + _activityManager.Create(new ActivityLog( + string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), + "UserDownloadingContent", + auth.UserId) + { + ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), auth.Client, auth.Device), + }); + } + catch + { + // Logged at lower levels + } + } + + private QueryResult GetSimilarItemsResult( + BaseItem item, + string excludeArtistIds, + Guid userId, + int? limit, + string fields, + string[] includeItemTypes) + { + var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null; + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request); + + var query = new InternalItemsQuery(user) + { + Limit = limit, + IncludeItemTypes = includeItemTypes, + SimilarTo = item, + DtoOptions = dtoOptions, + EnableTotalRecordCount = false + }; + + // ExcludeArtistIds + if (!string.IsNullOrEmpty(excludeArtistIds)) + { + query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); + } + + List itemsResult; + + if (item is MusicArtist) + { + query.IncludeItemTypes = Array.Empty(); + + itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList(); + } + else + { + itemsResult = _libraryManager.GetItemList(query); + } + + var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); + + var result = new QueryResult + { + Items = returnList, + TotalRecordCount = itemsResult.Count + }; + + return result; + } + + private static string[] GetRepresentativeItemTypes(string contentType) + { + return contentType switch + { + CollectionType.BoxSets => new[] { "BoxSet" }, + CollectionType.Playlists => new[] { "Playlist" }, + CollectionType.Movies => new[] { "Movie" }, + CollectionType.TvShows => new[] { "Series", "Season", "Episode" }, + CollectionType.Books => new[] { "Book" }, + CollectionType.Music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" }, + CollectionType.HomeVideos => new[] { "Video", "Photo" }, + CollectionType.Photos => new[] { "Video", "Photo" }, + CollectionType.MusicVideos => new[] { "MusicVideo" }, + _ => new[] { "Series", "Season", "Episode", "Movie" } + }; + } + + private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary) + { + if (isNewLibrary) + { + return false; + } + + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + + return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparer.OrdinalIgnoreCase)); + } + + private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + { + if (isNewLibrary) + { + if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) + { + return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) + || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) + || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase)); + } + + return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase); + } + + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + return metadataOptions.Length == 0 + || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase)); + } + + private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + { + if (isNewLibrary) + { + if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) + { + return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase); + } + + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (metadataOptions.Length == 0) + { + return true; + } + + return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase)); } } } diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 9f4d34f9c..a57cf146f 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; namespace Jellyfin.Api.Helpers { @@ -25,5 +26,22 @@ namespace Jellyfin.Api.Helpers ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries) : value.Split(separator); } + + /// + /// Splits a comma delimited string and parses Guids. + /// + /// Input value. + /// Parsed Guids. + public static Guid[] GetGuids(string value) + { + if (value == null) + { + return Array.Empty(); + } + + return value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(i => new Guid(i)) + .ToArray(); + } } } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs new file mode 100644 index 000000000..358434434 --- /dev/null +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Models.LibraryDtos +{ + /// + /// Library option info dto. + /// + public class LibraryOptionInfoDto + { + /// + /// Gets or sets name. + /// + public string? Name { get; set; } + + /// + /// Gets or sets a value indicating whether default enabled. + /// + public bool DefaultEnabled { get; set; } + } +} diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs new file mode 100644 index 000000000..33eda33cb --- /dev/null +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Jellyfin.Api.Models.LibraryDtos +{ + /// + /// Library options result dto. + /// + public class LibraryOptionsResultDto + { + /// + /// Gets or sets the metadata savers. + /// + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataSavers", Justification = "Imported from ServiceStack")] + public LibraryOptionInfoDto[] MetadataSavers { get; set; } = null!; + + /// + /// Gets or sets the metadata readers. + /// + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataReaders", Justification = "Imported from ServiceStack")] + public LibraryOptionInfoDto[] MetadataReaders { get; set; } = null!; + + /// + /// Gets or sets the subtitle fetchers. + /// + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SubtitleFetchers", Justification = "Imported from ServiceStack")] + public LibraryOptionInfoDto[] SubtitleFetchers { get; set; } = null!; + + /// + /// Gets or sets the type options. + /// + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "TypeOptions", Justification = "Imported from ServiceStack")] + public LibraryTypeOptionsDto[] TypeOptions { get; set; } = null!; + } +} diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs new file mode 100644 index 000000000..ad031e95e --- /dev/null +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.Api.Models.LibraryDtos +{ + /// + /// Library type options dto. + /// + public class LibraryTypeOptionsDto + { + /// + /// Gets or sets the type. + /// + public string? Type { get; set; } + + /// + /// Gets or sets the metadata fetchers. + /// + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "MetadataFetchers", Justification = "Imported from ServiceStack")] + public LibraryOptionInfoDto[] MetadataFetchers { get; set; } = null!; + + /// + /// Gets or sets the image fetchers. + /// + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "ImageFetchers", Justification = "Imported from ServiceStack")] + public LibraryOptionInfoDto[] ImageFetchers { get; set; } = null!; + + /// + /// Gets or sets the supported image types. + /// + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "SupportedImageTypes", Justification = "Imported from ServiceStack")] + public ImageType[] SupportedImageTypes { get; set; } = null!; + + /// + /// Gets or sets the default image options. + /// + [SuppressMessage("Microsoft.Performance", "CA1819:ReturnArrays", MessageId = "DefaultImageOptions", Justification = "Imported from ServiceStack")] + public ImageOption[] DefaultImageOptions { get; set; } = null!; + } +} diff --git a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs new file mode 100644 index 000000000..991dbfc50 --- /dev/null +++ b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs @@ -0,0 +1,19 @@ +namespace Jellyfin.Api.Models.LibraryDtos +{ + /// + /// Media Update Info Dto. + /// + public class MediaUpdateInfoDto + { + /// + /// Gets or sets media path. + /// + public string? Path { get; set; } + + /// + /// Gets or sets media update type. + /// Created, Modified, Deleted. + /// + public string? UpdateType { get; set; } + } +} diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index dbd5ba416..5f2fb7ea6 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using System.Reflection; using Jellyfin.Api; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; +using Jellyfin.Api.Auth.DownloadPolicy; using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; using Jellyfin.Api.Auth.IgnoreSchedulePolicy; using Jellyfin.Api.Auth.LocalAccessPolicy; @@ -39,6 +40,7 @@ namespace Jellyfin.Server.Extensions public static IServiceCollection AddJellyfinApiAuthorization(this IServiceCollection serviceCollection) { serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); @@ -52,6 +54,13 @@ namespace Jellyfin.Server.Extensions policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); policy.AddRequirements(new DefaultAuthorizationRequirement()); }); + options.AddPolicy( + Policies.Download, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new DownloadRequirement()); + }); options.AddPolicy( Policies.FirstTimeSetupOrElevated, policy => diff --git a/MediaBrowser.Api/Library/LibraryService.cs b/MediaBrowser.Api/Library/LibraryService.cs deleted file mode 100644 index e96875403..000000000 --- a/MediaBrowser.Api/Library/LibraryService.cs +++ /dev/null @@ -1,1116 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Entities; -using MediaBrowser.Api.Movies; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Activity; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; -using Book = MediaBrowser.Controller.Entities.Book; -using Episode = MediaBrowser.Controller.Entities.TV.Episode; -using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider; -using Movie = MediaBrowser.Controller.Entities.Movies.Movie; -using MusicAlbum = MediaBrowser.Controller.Entities.Audio.MusicAlbum; -using Series = MediaBrowser.Controller.Entities.TV.Series; - -namespace MediaBrowser.Api.Library -{ - [Route("/Items/{Id}/File", "GET", Summary = "Gets the original file of an item")] - [Authenticated] - public class GetFile - { - /// - /// Gets or sets the id. - /// - /// The id. - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - /// - /// Class GetCriticReviews - /// - [Route("/Items/{Id}/CriticReviews", "GET", Summary = "Gets critic reviews for an item")] - [Authenticated] - public class GetCriticReviews : IReturn> - { - /// - /// Gets or sets the id. - /// - /// The id. - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - /// - /// Skips over a given number of items within the results. Use for paging. - /// - /// The start index. - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// - /// The maximum number of items to return - /// - /// The limit. - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - } - - /// - /// Class GetThemeSongs - /// - [Route("/Items/{Id}/ThemeSongs", "GET", Summary = "Gets theme songs for an item")] - [Authenticated] - public class GetThemeSongs : IReturn - { - /// - /// Gets or sets the user id. - /// - /// The user id. - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// - /// Gets or sets the id. - /// - /// The id. - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "InheritFromParent", Description = "Determines whether or not parent items should be searched for theme media.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool InheritFromParent { get; set; } - } - - /// - /// Class GetThemeVideos - /// - [Route("/Items/{Id}/ThemeVideos", "GET", Summary = "Gets theme videos for an item")] - [Authenticated] - public class GetThemeVideos : IReturn - { - /// - /// Gets or sets the user id. - /// - /// The user id. - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// - /// Gets or sets the id. - /// - /// The id. - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "InheritFromParent", Description = "Determines whether or not parent items should be searched for theme media.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool InheritFromParent { get; set; } - } - - /// - /// Class GetThemeVideos - /// - [Route("/Items/{Id}/ThemeMedia", "GET", Summary = "Gets theme videos and songs for an item")] - [Authenticated] - public class GetThemeMedia : IReturn - { - /// - /// Gets or sets the user id. - /// - /// The user id. - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// - /// Gets or sets the id. - /// - /// The id. - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "InheritFromParent", Description = "Determines whether or not parent items should be searched for theme media.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public bool InheritFromParent { get; set; } - } - - [Route("/Library/Refresh", "POST", Summary = "Starts a library scan")] - [Authenticated(Roles = "Admin")] - public class RefreshLibrary : IReturnVoid - { - } - - [Route("/Items/{Id}", "DELETE", Summary = "Deletes an item from the library and file system")] - [Authenticated] - public class DeleteItem : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/Items", "DELETE", Summary = "Deletes an item from the library and file system")] - [Authenticated] - public class DeleteItems : IReturnVoid - { - [ApiMember(Name = "Ids", Description = "Ids", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string Ids { get; set; } - } - - [Route("/Items/Counts", "GET")] - [Authenticated] - public class GetItemCounts : IReturn - { - [ApiMember(Name = "UserId", Description = "Optional. Get counts from a specific user's library.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - [ApiMember(Name = "IsFavorite", Description = "Optional. Get counts of favorite items", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsFavorite { get; set; } - } - - [Route("/Items/{Id}/Ancestors", "GET", Summary = "Gets all parents of an item")] - [Authenticated] - public class GetAncestors : IReturn - { - /// - /// Gets or sets the user id. - /// - /// The user id. - [ApiMember(Name = "UserId", Description = "Optional. Filter by user id, and attach user data", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// - /// Gets or sets the id. - /// - /// The id. - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - /// - /// Class GetPhyscialPaths - /// - [Route("/Library/PhysicalPaths", "GET", Summary = "Gets a list of physical paths from virtual folders")] - [Authenticated(Roles = "Admin")] - public class GetPhyscialPaths : IReturn> - { - } - - [Route("/Library/MediaFolders", "GET", Summary = "Gets all user media folders.")] - [Authenticated] - public class GetMediaFolders : IReturn> - { - [ApiMember(Name = "IsHidden", Description = "Optional. Filter by folders that are marked hidden, or not.", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? IsHidden { get; set; } - } - - [Route("/Library/Series/Added", "POST", Summary = "Reports that new episodes of a series have been added by an external source")] - [Route("/Library/Series/Updated", "POST", Summary = "Reports that new episodes of a series have been added by an external source")] - [Authenticated] - public class PostUpdatedSeries : IReturnVoid - { - [ApiMember(Name = "TvdbId", Description = "Tvdb Id", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "POST")] - public string TvdbId { get; set; } - } - - [Route("/Library/Movies/Added", "POST", Summary = "Reports that new movies have been added by an external source")] - [Route("/Library/Movies/Updated", "POST", Summary = "Reports that new movies have been added by an external source")] - [Authenticated] - public class PostUpdatedMovies : IReturnVoid - { - [ApiMember(Name = "TmdbId", Description = "Tmdb Id", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "POST")] - public string TmdbId { get; set; } - [ApiMember(Name = "ImdbId", Description = "Imdb Id", IsRequired = false, DataType = "string", ParameterType = "path", Verb = "POST")] - public string ImdbId { get; set; } - } - - public class MediaUpdateInfo - { - public string Path { get; set; } - - // Created, Modified, Deleted - public string UpdateType { get; set; } - } - - [Route("/Library/Media/Updated", "POST", Summary = "Reports that new movies have been added by an external source")] - [Authenticated] - public class PostUpdatedMedia : IReturnVoid - { - [ApiMember(Name = "Updates", Description = "A list of updated media paths", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public List Updates { get; set; } - } - - [Route("/Items/{Id}/Download", "GET", Summary = "Downloads item media")] - [Authenticated(Roles = "download")] - public class GetDownload - { - /// - /// Gets or sets the id. - /// - /// The id. - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Artists/{Id}/Similar", "GET", Summary = "Finds albums similar to a given album.")] - [Route("/Items/{Id}/Similar", "GET", Summary = "Gets similar items")] - [Route("/Albums/{Id}/Similar", "GET", Summary = "Finds albums similar to a given album.")] - [Route("/Shows/{Id}/Similar", "GET", Summary = "Finds tv shows similar to a given one.")] - [Route("/Movies/{Id}/Similar", "GET", Summary = "Finds movies and trailers similar to a given movie.")] - [Route("/Trailers/{Id}/Similar", "GET", Summary = "Finds movies and trailers similar to a given trailer.")] - [Authenticated] - public class GetSimilarItems : BaseGetSimilarItemsFromItem - { - } - - [Route("/Libraries/AvailableOptions", "GET")] - [Authenticated(AllowBeforeStartupWizard = true)] - public class GetLibraryOptionsInfo : IReturn - { - public string LibraryContentType { get; set; } - public bool IsNewLibrary { get; set; } - } - - public class LibraryOptionInfo - { - public string Name { get; set; } - public bool DefaultEnabled { get; set; } - } - - public class LibraryOptionsResult - { - public LibraryOptionInfo[] MetadataSavers { get; set; } - public LibraryOptionInfo[] MetadataReaders { get; set; } - public LibraryOptionInfo[] SubtitleFetchers { get; set; } - public LibraryTypeOptions[] TypeOptions { get; set; } - } - - public class LibraryTypeOptions - { - public string Type { get; set; } - public LibraryOptionInfo[] MetadataFetchers { get; set; } - public LibraryOptionInfo[] ImageFetchers { get; set; } - public ImageType[] SupportedImageTypes { get; set; } - public ImageOption[] DefaultImageOptions { get; set; } - } - - /// - /// Class LibraryService - /// - public class LibraryService : BaseApiService - { - private readonly IProviderManager _providerManager; - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - private readonly IActivityManager _activityManager; - private readonly ILocalizationManager _localization; - private readonly ILibraryMonitor _libraryMonitor; - - private readonly ILogger _moviesServiceLogger; - - /// - /// Initializes a new instance of the class. - /// - public LibraryService( - ILogger logger, - ILogger moviesServiceLogger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IProviderManager providerManager, - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService, - IAuthorizationContext authContext, - IActivityManager activityManager, - ILocalizationManager localization, - ILibraryMonitor libraryMonitor) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _providerManager = providerManager; - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - _authContext = authContext; - _activityManager = activityManager; - _localization = localization; - _libraryMonitor = libraryMonitor; - _moviesServiceLogger = moviesServiceLogger; - } - - // Content Types available for each Library - private string[] GetRepresentativeItemTypes(string contentType) - { - return contentType switch - { - CollectionType.BoxSets => new[] {"BoxSet"}, - CollectionType.Playlists => new[] {"Playlist"}, - CollectionType.Movies => new[] {"Movie"}, - CollectionType.TvShows => new[] {"Series", "Season", "Episode"}, - CollectionType.Books => new[] {"Book"}, - CollectionType.Music => new[] {"MusicArtist", "MusicAlbum", "Audio", "MusicVideo"}, - CollectionType.HomeVideos => new[] {"Video", "Photo"}, - CollectionType.Photos => new[] {"Video", "Photo"}, - CollectionType.MusicVideos => new[] {"MusicVideo"}, - _ => new[] {"Series", "Season", "Episode", "Movie"} - }; - } - - private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary) - { - if (isNewLibrary) - { - return false; - } - - var metadataOptions = ServerConfigurationManager.Configuration.MetadataOptions - .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparer.OrdinalIgnoreCase)) - .ToArray(); - - if (metadataOptions.Length == 0) - { - return true; - } - - return metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparer.OrdinalIgnoreCase)); - } - - private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary) - { - if (isNewLibrary) - { - if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) - { - return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) - || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) - || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase)); - } - - return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase); - } - - var metadataOptions = ServerConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - return metadataOptions.Length == 0 - || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase)); - } - - private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) - { - if (isNewLibrary) - { - if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) - { - return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase); - } - - return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase); - } - - var metadataOptions = ServerConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - if (metadataOptions.Length == 0) - { - return true; - } - - return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase)); - } - - public object Get(GetLibraryOptionsInfo request) - { - var result = new LibraryOptionsResult(); - - var types = GetRepresentativeItemTypes(request.LibraryContentType); - var isNewLibrary = request.IsNewLibrary; - var typesList = types.ToList(); - - var plugins = _providerManager.GetAllMetadataPlugins() - .Where(i => types.Contains(i.ItemType, StringComparer.OrdinalIgnoreCase)) - .OrderBy(i => typesList.IndexOf(i.ItemType)) - .ToList(); - - result.MetadataSavers = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver)) - .Select(i => new LibraryOptionInfo - { - Name = i.Name, - DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary) - }) - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()) - .ToArray(); - - result.MetadataReaders = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider)) - .Select(i => new LibraryOptionInfo - { - Name = i.Name, - DefaultEnabled = true - }) - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()) - .ToArray(); - - result.SubtitleFetchers = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher)) - .Select(i => new LibraryOptionInfo - { - Name = i.Name, - DefaultEnabled = true - }) - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()) - .ToArray(); - - var typeOptions = new List(); - - foreach (var type in types) - { - TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions); - - typeOptions.Add(new LibraryTypeOptions - { - Type = type, - - MetadataFetchers = plugins - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher)) - .Select(i => new LibraryOptionInfo - { - Name = i.Name, - DefaultEnabled = IsMetadataFetcherEnabledByDefault(i.Name, type, isNewLibrary) - }) - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()) - .ToArray(), - - ImageFetchers = plugins - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher)) - .Select(i => new LibraryOptionInfo - { - Name = i.Name, - DefaultEnabled = IsImageFetcherEnabledByDefault(i.Name, type, isNewLibrary) - }) - .GroupBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => x.First()) - .ToArray(), - - SupportedImageTypes = plugins - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => i.SupportedImageTypes ?? Array.Empty()) - .Distinct() - .ToArray(), - - DefaultImageOptions = defaultImageOptions ?? Array.Empty() - }); - } - - result.TypeOptions = typeOptions.ToArray(); - - return result; - } - - public object Get(GetSimilarItems request) - { - var item = string.IsNullOrEmpty(request.Id) ? - (!request.UserId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : - _libraryManager.RootFolder) : _libraryManager.GetItemById(request.Id); - - var program = item as IHasProgramAttributes; - - if (item is Movie || (program != null && program.IsMovie) || item is Trailer) - { - return new MoviesService( - _moviesServiceLogger, - ServerConfigurationManager, - ResultFactory, - _userManager, - _libraryManager, - _dtoService, - _authContext) - { - Request = Request, - - }.GetSimilarItemsResult(request); - } - - if (program != null && program.IsSeries) - { - return GetSimilarItemsResult(request, new[] { typeof(Series).Name }); - } - - if (item is Episode || (item is IItemByName && !(item is MusicArtist))) - { - return new QueryResult(); - } - - return GetSimilarItemsResult(request, new[] { item.GetType().Name }); - } - - private QueryResult GetSimilarItemsResult(BaseGetSimilarItemsFromItem request, string[] includeItemTypes) - { - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var item = string.IsNullOrEmpty(request.Id) ? - (!request.UserId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : - _libraryManager.RootFolder) : _libraryManager.GetItemById(request.Id); - - var dtoOptions = GetDtoOptions(_authContext, request); - - var query = new InternalItemsQuery(user) - { - Limit = request.Limit, - IncludeItemTypes = includeItemTypes, - SimilarTo = item, - DtoOptions = dtoOptions, - EnableTotalRecordCount = false - }; - - // ExcludeArtistIds - if (!string.IsNullOrEmpty(request.ExcludeArtistIds)) - { - query.ExcludeArtistIds = GetGuids(request.ExcludeArtistIds); - } - - List itemsResult; - - if (item is MusicArtist) - { - query.IncludeItemTypes = Array.Empty(); - - itemsResult = _libraryManager.GetArtists(query).Items.Select(i => i.Item1).ToList(); - } - else - { - itemsResult = _libraryManager.GetItemList(query); - } - - var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); - - var result = new QueryResult - { - Items = returnList, - TotalRecordCount = itemsResult.Count - }; - - return result; - } - - public object Get(GetMediaFolders request) - { - var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList(); - - if (request.IsHidden.HasValue) - { - var val = request.IsHidden.Value; - - items = items.Where(i => i.IsHidden == val).ToList(); - } - - var dtoOptions = GetDtoOptions(_authContext, request); - - var result = new QueryResult - { - TotalRecordCount = items.Count, - - Items = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions)).ToArray() - }; - - return result; - } - - public void Post(PostUpdatedSeries request) - { - var series = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { typeof(Series).Name }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - - }).Where(i => string.Equals(request.TvdbId, i.GetProviderId(MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray(); - - foreach (var item in series) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } - } - - public void Post(PostUpdatedMedia request) - { - if (request.Updates != null) - { - foreach (var item in request.Updates) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } - } - } - - public void Post(PostUpdatedMovies request) - { - var movies = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { typeof(Movie).Name }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - - }); - - if (!string.IsNullOrWhiteSpace(request.ImdbId)) - { - movies = movies.Where(i => string.Equals(request.ImdbId, i.GetProviderId(MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList(); - } - else if (!string.IsNullOrWhiteSpace(request.TmdbId)) - { - movies = movies.Where(i => string.Equals(request.TmdbId, i.GetProviderId(MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList(); - } - else - { - movies = new List(); - } - - foreach (var item in movies) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } - } - - public Task Get(GetDownload request) - { - var item = _libraryManager.GetItemById(request.Id); - var auth = _authContext.GetAuthorizationInfo(Request); - - var user = auth.User; - - if (user != null) - { - if (!item.CanDownload(user)) - { - throw new ArgumentException("Item does not support downloading"); - } - } - else - { - if (!item.CanDownload()) - { - throw new ArgumentException("Item does not support downloading"); - } - } - - var headers = new Dictionary(); - - if (user != null) - { - LogDownload(item, user, auth); - } - - var path = item.Path; - - // Quotes are valid in linux. They'll possibly cause issues here - var filename = (Path.GetFileName(path) ?? string.Empty).Replace("\"", string.Empty); - if (!string.IsNullOrWhiteSpace(filename)) - { - // Kestrel doesn't support non-ASCII characters in headers - if (Regex.IsMatch(filename, @"[^\p{IsBasicLatin}]")) - { - // Manually encoding non-ASCII characters, following https://tools.ietf.org/html/rfc5987#section-3.2.2 - headers[HeaderNames.ContentDisposition] = "attachment; filename*=UTF-8''" + WebUtility.UrlEncode(filename); - } - else - { - headers[HeaderNames.ContentDisposition] = "attachment; filename=\"" + filename + "\""; - } - } - - return ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions - { - Path = path, - ResponseHeaders = headers - }); - } - - private void LogDownload(BaseItem item, User user, AuthorizationInfo auth) - { - try - { - _activityManager.Create(new ActivityLog( - string.Format(_localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), - "UserDownloadingContent", - auth.UserId) - { - ShortOverview = string.Format(_localization.GetLocalizedString("AppDeviceValues"), auth.Client, auth.Device), - }); - } - catch - { - // Logged at lower levels - } - } - - public Task Get(GetFile request) - { - var item = _libraryManager.GetItemById(request.Id); - - return ResultFactory.GetStaticFileResult(Request, item.Path); - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public object Get(GetPhyscialPaths request) - { - var result = _libraryManager.RootFolder.Children - .SelectMany(c => c.PhysicalLocations) - .ToList(); - - return ToOptimizedResult(result); - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public object Get(GetAncestors request) - { - var result = GetAncestors(request); - - return ToOptimizedResult(result); - } - - /// - /// Gets the ancestors. - /// - /// The request. - /// Task{BaseItemDto[]}. - public List GetAncestors(GetAncestors request) - { - var item = _libraryManager.GetItemById(request.Id); - - var baseItemDtos = new List(); - - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var dtoOptions = GetDtoOptions(_authContext, request); - - BaseItem parent = item.GetParent(); - - while (parent != null) - { - if (user != null) - { - parent = TranslateParentItem(parent, user); - } - - baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); - - parent = parent.GetParent(); - } - - return baseItemDtos; - } - - private BaseItem TranslateParentItem(BaseItem item, User user) - { - return item.GetParent() is AggregateFolder - ? _libraryManager.GetUserRootFolder().GetChildren(user, true) - .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path)) - : item; - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public object Get(GetCriticReviews request) - { - return new QueryResult(); - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public object Get(GetItemCounts request) - { - var user = request.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(request.UserId); - - var counts = new ItemCounts - { - AlbumCount = GetCount(typeof(MusicAlbum), user, request), - EpisodeCount = GetCount(typeof(Episode), user, request), - MovieCount = GetCount(typeof(Movie), user, request), - SeriesCount = GetCount(typeof(Series), user, request), - SongCount = GetCount(typeof(Audio), user, request), - MusicVideoCount = GetCount(typeof(MusicVideo), user, request), - BoxSetCount = GetCount(typeof(BoxSet), user, request), - BookCount = GetCount(typeof(Book), user, request) - }; - - return ToOptimizedResult(counts); - } - - private int GetCount(Type type, User user, GetItemCounts request) - { - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { type.Name }, - Limit = 0, - Recursive = true, - IsVirtualItem = false, - IsFavorite = request.IsFavorite, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }; - - return _libraryManager.GetItemsResult(query).TotalRecordCount; - } - - /// - /// Posts the specified request. - /// - /// The request. - public async Task Post(RefreshLibrary request) - { - try - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error refreshing library"); - } - } - - /// - /// Deletes the specified request. - /// - /// The request. - public void Delete(DeleteItems request) - { - var ids = string.IsNullOrWhiteSpace(request.Ids) - ? Array.Empty() - : request.Ids.Split(','); - - foreach (var i in ids) - { - var item = _libraryManager.GetItemById(i); - var auth = _authContext.GetAuthorizationInfo(Request); - var user = auth.User; - - if (!item.CanDelete(user)) - { - if (ids.Length > 1) - { - throw new SecurityException("Unauthorized access"); - } - - continue; - } - - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = true - }, true); - } - } - - /// - /// Deletes the specified request. - /// - /// The request. - public void Delete(DeleteItem request) - { - Delete(new DeleteItems - { - Ids = request.Id - }); - } - - public object Get(GetThemeMedia request) - { - var themeSongs = GetThemeSongs(new GetThemeSongs - { - InheritFromParent = request.InheritFromParent, - Id = request.Id, - UserId = request.UserId - - }); - - var themeVideos = GetThemeVideos(new GetThemeVideos - { - InheritFromParent = request.InheritFromParent, - Id = request.Id, - UserId = request.UserId - - }); - - return ToOptimizedResult(new AllThemeMediaResult - { - ThemeSongsResult = themeSongs, - ThemeVideosResult = themeVideos, - - SoundtrackSongsResult = new ThemeMediaResult() - }); - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public object Get(GetThemeSongs request) - { - var result = GetThemeSongs(request); - - return ToOptimizedResult(result); - } - - private ThemeMediaResult GetThemeSongs(GetThemeSongs request) - { - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var item = string.IsNullOrEmpty(request.Id) - ? (!request.UserId.Equals(Guid.Empty) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.RootFolder) - : _libraryManager.GetItemById(request.Id); - - if (item == null) - { - throw new ResourceNotFoundException("Item not found."); - } - - IEnumerable themeItems; - - while (true) - { - themeItems = item.GetThemeSongs(); - - if (themeItems.Any() || !request.InheritFromParent) - { - break; - } - - var parent = item.GetParent(); - if (parent == null) - { - break; - } - item = parent; - } - - var dtoOptions = GetDtoOptions(_authContext, request); - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); - - return new ThemeMediaResult - { - Items = items, - TotalRecordCount = items.Length, - OwnerId = item.Id - }; - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public object Get(GetThemeVideos request) - { - return ToOptimizedResult(GetThemeVideos(request)); - } - - public ThemeMediaResult GetThemeVideos(GetThemeVideos request) - { - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var item = string.IsNullOrEmpty(request.Id) - ? (!request.UserId.Equals(Guid.Empty) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.RootFolder) - : _libraryManager.GetItemById(request.Id); - - if (item == null) - { - throw new ResourceNotFoundException("Item not found."); - } - - IEnumerable themeItems; - - while (true) - { - themeItems = item.GetThemeVideos(); - - if (themeItems.Any() || !request.InheritFromParent) - { - break; - } - - var parent = item.GetParent(); - if (parent == null) - { - break; - } - item = parent; - } - - var dtoOptions = GetDtoOptions(_authContext, request); - - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); - - return new ThemeMediaResult - { - Items = items, - TotalRecordCount = items.Length, - OwnerId = item.Id - }; - } - } -} diff --git a/MediaBrowser.Api/MediaBrowser.Api.csproj b/MediaBrowser.Api/MediaBrowser.Api.csproj index d703bdb05..cd329c94f 100644 --- a/MediaBrowser.Api/MediaBrowser.Api.csproj +++ b/MediaBrowser.Api/MediaBrowser.Api.csproj @@ -14,6 +14,10 @@ + + + + netstandard2.1 false -- cgit v1.2.3 From 8d7b39a36e388bda2bc09f3896714bdefa2866b7 Mon Sep 17 00:00:00 2001 From: crobibero Date: Thu, 25 Jun 2020 17:44:11 -0600 Subject: fix endpoint order --- Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs') diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index e4ecd343c..cfbabf795 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -195,7 +195,7 @@ namespace Jellyfin.Server.Extensions // Order actions by route path, then by http method. c.OrderActionsBy(description => - $"{description.ActionDescriptor.RouteValues["controller"]}_{description.HttpMethod}"); + $"{description.ActionDescriptor.RouteValues["controller"]}_{description.RelativePath}"); // Use method name as operationId c.CustomOperationIds(description => -- cgit v1.2.3