diff options
56 files changed, 3105 insertions, 2885 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 5772dd479..25ee7e9ec 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -97,7 +97,6 @@ using MediaBrowser.Providers.Chapters; using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Plugins.TheTvdb; using MediaBrowser.Providers.Subtitles; -using MediaBrowser.WebDashboard.Api; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -1037,9 +1036,6 @@ namespace Emby.Server.Implementations // Include composable parts in the Api assembly yield return typeof(ApiEntryPoint).Assembly; - // Include composable parts in the Dashboard assembly - yield return typeof(DashboardService).Assembly; - // Include composable parts in the Model assembly yield return typeof(SystemInfo).Assembly; diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index e71e437ac..5272e2692 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -13,7 +13,6 @@ <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> <ProjectReference Include="..\MediaBrowser.Providers\MediaBrowser.Providers.csproj" /> - <ProjectReference Include="..\MediaBrowser.WebDashboard\MediaBrowser.WebDashboard.csproj" /> <ProjectReference Include="..\MediaBrowser.XbmcMetadata\MediaBrowser.XbmcMetadata.csproj" /> <ProjectReference Include="..\Emby.Dlna\Emby.Dlna.csproj" /> <ProjectReference Include="..\MediaBrowser.Api\MediaBrowser.Api.csproj" /> diff --git a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs index b5b9d8904..953acac80 100644 --- a/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/BaseAuthorizationHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Net; +using System.Net; using System.Security.Claims; using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 5e5e25e84..ea02e6a0b 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -1,5 +1,3 @@ -#nullable enable - using System.Globalization; using System.Security.Authentication; using System.Security.Claims; diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index 4ae7cf506..ec50fb022 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -1,6 +1,5 @@ -#pragma warning disable CA1801 - using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Data.Entities; @@ -41,6 +40,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns> [HttpGet("Entries")] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "hasUserId", Justification = "Imported from ServiceStack")] public ActionResult<QueryResult<ActivityLogEntry>> GetLogEntries( [FromQuery] int? startIndex, [FromQuery] int? limit, diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs new file mode 100644 index 000000000..67790c0e4 --- /dev/null +++ b/Jellyfin.Api/Controllers/BrandingController.cs @@ -0,0 +1,57 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Branding; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Branding controller. + /// </summary> + public class BrandingController : BaseJellyfinApiController + { + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="BrandingController"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public BrandingController(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// <summary> + /// Gets branding configuration. + /// </summary> + /// <response code="200">Branding configuration returned.</response> + /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns> + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BrandingOptions> GetBrandingOptions() + { + return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + } + + /// <summary> + /// Gets branding css. + /// </summary> + /// <response code="200">Branding css returned.</response> + /// <response code="204">No branding css configured.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the branding css if exist, + /// or a <see cref="NoContentResult"/> if the css is not configured. + /// </returns> + [HttpGet("Css")] + [HttpGet("Css.css")] + [Produces("text/css")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult<string> GetBrandingCss() + { + var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + return options.CustomCss ?? string.Empty; + } + } +} diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index 74f1677bd..d275ed2eb 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -68,7 +68,7 @@ namespace Jellyfin.Api.Controllers /// <param name="key">Configuration key.</param> /// <response code="200">Configuration returned.</response> /// <returns>Configuration.</returns> - [HttpGet("Configuration/{Key}")] + [HttpGet("Configuration/{key}")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<object> GetNamedConfiguration([FromRoute] string key) { @@ -81,7 +81,7 @@ namespace Jellyfin.Api.Controllers /// <param name="key">Configuration key.</param> /// <response code="204">Named configuration updated.</response> /// <returns>Update status.</returns> - [HttpPost("Configuration/{Key}")] + [HttpPost("Configuration/{key}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string key) diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs new file mode 100644 index 000000000..aab920ff3 --- /dev/null +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using Jellyfin.Api.Models; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Extensions; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Plugins; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The dashboard controller. + /// </summary> + public class DashboardController : BaseJellyfinApiController + { + private readonly ILogger<DashboardController> _logger; + private readonly IServerApplicationHost _appHost; + private readonly IConfiguration _appConfig; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IResourceFileManager _resourceFileManager; + + /// <summary> + /// Initializes a new instance of the <see cref="DashboardController"/> class. + /// </summary> + /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param> + /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> + /// <param name="appConfig">Instance of <see cref="IConfiguration"/> interface.</param> + /// <param name="resourceFileManager">Instance of <see cref="IResourceFileManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + public DashboardController( + ILogger<DashboardController> logger, + IServerApplicationHost appHost, + IConfiguration appConfig, + IResourceFileManager resourceFileManager, + IServerConfigurationManager serverConfigurationManager) + { + _logger = logger; + _appHost = appHost; + _appConfig = appConfig; + _resourceFileManager = resourceFileManager; + _serverConfigurationManager = serverConfigurationManager; + } + + /// <summary> + /// Gets the path of the directory containing the static web interface content, or null if the server is not + /// hosting the web client. + /// </summary> + private string? WebClientUiPath => GetWebClientUiPath(_appConfig, _serverConfigurationManager); + + /// <summary> + /// Gets the configuration pages. + /// </summary> + /// <param name="enableInMainMenu">Whether to enable in the main menu.</param> + /// <param name="pageType">The <see cref="ConfigurationPageInfo"/>.</param> + /// <response code="200">ConfigurationPages returned.</response> + /// <response code="404">Server still loading.</response> + /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns> + [HttpGet("/web/ConfigurationPages")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ConfigurationPageInfo?>> GetConfigurationPages( + [FromQuery] bool? enableInMainMenu, + [FromQuery] ConfigurationPageType? pageType) + { + const string unavailableMessage = "The server is still loading. Please try again momentarily."; + + var pages = _appHost.GetExports<IPluginConfigurationPage>().ToList(); + + if (pages == null) + { + return NotFound(unavailableMessage); + } + + // Don't allow a failing plugin to fail them all + var configPages = pages.Select(p => + { + try + { + return new ConfigurationPageInfo(p); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting plugin information from {Plugin}", p.GetType().Name); + return null; + } + }) + .Where(i => i != null) + .ToList(); + + configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages)); + + if (pageType.HasValue) + { + configPages = configPages.Where(p => p!.ConfigurationPageType == pageType).ToList(); + } + + if (enableInMainMenu.HasValue) + { + configPages = configPages.Where(p => p!.EnableInMainMenu == enableInMainMenu.Value).ToList(); + } + + return configPages; + } + + /// <summary> + /// Gets a dashboard configuration page. + /// </summary> + /// <param name="name">The name of the page.</param> + /// <response code="200">ConfigurationPage returned.</response> + /// <response code="404">Plugin configuration page not found.</response> + /// <returns>The configuration page.</returns> + [HttpGet("/web/ConfigurationPage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetDashboardConfigurationPage([FromQuery] string name) + { + IPlugin? plugin = null; + Stream? stream = null; + + var isJs = false; + var isTemplate = false; + + var page = _appHost.GetExports<IPluginConfigurationPage>().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + if (page != null) + { + plugin = page.Plugin; + stream = page.GetHtmlStream(); + } + + if (plugin == null) + { + var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase)); + if (altPage != null) + { + plugin = altPage.Item2; + stream = plugin.GetType().Assembly.GetManifestResourceStream(altPage.Item1.EmbeddedResourcePath); + + isJs = string.Equals(Path.GetExtension(altPage.Item1.EmbeddedResourcePath), ".js", StringComparison.OrdinalIgnoreCase); + isTemplate = altPage.Item1.EmbeddedResourcePath.EndsWith(".template.html", StringComparison.Ordinal); + } + } + + if (plugin != null && stream != null) + { + if (isJs) + { + return File(stream, MimeTypes.GetMimeType("page.js")); + } + + if (isTemplate) + { + return File(stream, MimeTypes.GetMimeType("page.html")); + } + + return File(stream, MimeTypes.GetMimeType("page.html")); + } + + return NotFound(); + } + + /// <summary> + /// Gets the robots.txt. + /// </summary> + /// <response code="200">Robots.txt returned.</response> + /// <returns>The robots.txt.</returns> + [HttpGet("/robots.txt")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ApiExplorerSettings(IgnoreApi = true)] + public ActionResult GetRobotsTxt() + { + return GetWebClientResource("robots.txt", string.Empty); + } + + /// <summary> + /// Gets a resource from the web client. + /// </summary> + /// <param name="resourceName">The resource name.</param> + /// <param name="v">The v.</param> + /// <response code="200">Web client returned.</response> + /// <response code="404">Server does not host a web client.</response> + /// <returns>The resource.</returns> + [HttpGet("/web/{*resourceName}")] + [ApiExplorerSettings(IgnoreApi = true)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "v", Justification = "Imported from ServiceStack")] + public ActionResult GetWebClientResource( + [FromRoute] string resourceName, + [FromQuery] string? v) + { + if (!_appConfig.HostWebClient() || WebClientUiPath == null) + { + return NotFound("Server does not host a web client."); + } + + var path = resourceName; + var basePath = WebClientUiPath; + + // Bounce them to the startup wizard if it hasn't been completed yet + if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted + && !Request.Path.Value.Contains("wizard", StringComparison.OrdinalIgnoreCase) + && Request.Path.Value.Contains("index", StringComparison.OrdinalIgnoreCase)) + { + return Redirect("index.html?start=wizard#!/wizardstart.html"); + } + + var stream = new FileStream(_resourceFileManager.GetResourcePath(basePath, path), FileMode.Open, FileAccess.Read); + return File(stream, MimeTypes.GetMimeType(path)); + } + + /// <summary> + /// Gets the favicon. + /// </summary> + /// <response code="200">Favicon.ico returned.</response> + /// <returns>The favicon.</returns> + [HttpGet("/favicon.ico")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ApiExplorerSettings(IgnoreApi = true)] + public ActionResult GetFavIcon() + { + return GetWebClientResource("favicon.ico", string.Empty); + } + + /// <summary> + /// Gets the path of the directory containing the static web interface content. + /// </summary> + /// <param name="appConfig">The app configuration.</param> + /// <param name="serverConfigManager">The server configuration manager.</param> + /// <returns>The directory path, or null if the server is not hosting the web client.</returns> + public static string? GetWebClientUiPath(IConfiguration appConfig, IServerConfigurationManager serverConfigManager) + { + if (!appConfig.HostWebClient()) + { + return null; + } + + if (!string.IsNullOrEmpty(serverConfigManager.Configuration.DashboardSourcePath)) + { + return serverConfigManager.Configuration.DashboardSourcePath; + } + + return serverConfigManager.ApplicationPaths.WebPath; + } + + private IEnumerable<ConfigurationPageInfo> GetConfigPages(IPlugin plugin) + { + return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1)); + } + + private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(IPlugin plugin) + { + if (!(plugin is IHasWebPages hasWebPages)) + { + return new List<Tuple<PluginPageInfo, IPlugin>>(); + } + + return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin)); + } + + private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages() + { + return _appHost.Plugins.SelectMany(GetPluginPages); + } + } +} diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index 78368eed6..55ca7b7c0 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -133,6 +133,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult DeleteDevice([FromQuery, BindRequired] string id) { var existingDevice = _deviceManager.GetDevice(id); diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs new file mode 100644 index 000000000..3f946d9d2 --- /dev/null +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -0,0 +1,76 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Display Preferences Controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class DisplayPreferencesController : BaseJellyfinApiController + { + private readonly IDisplayPreferencesRepository _displayPreferencesRepository; + + /// <summary> + /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class. + /// </summary> + /// <param name="displayPreferencesRepository">Instance of <see cref="IDisplayPreferencesRepository"/> interface.</param> + public DisplayPreferencesController(IDisplayPreferencesRepository displayPreferencesRepository) + { + _displayPreferencesRepository = displayPreferencesRepository; + } + + /// <summary> + /// Get Display Preferences. + /// </summary> + /// <param name="displayPreferencesId">Display preferences id.</param> + /// <param name="userId">User id.</param> + /// <param name="client">Client.</param> + /// <response code="200">Display preferences retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns> + [HttpGet("{displayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<DisplayPreferences> GetDisplayPreferences( + [FromRoute] string displayPreferencesId, + [FromQuery] [Required] string userId, + [FromQuery] [Required] string client) + { + return _displayPreferencesRepository.GetDisplayPreferences(displayPreferencesId, userId, client); + } + + /// <summary> + /// Update Display Preferences. + /// </summary> + /// <param name="displayPreferencesId">Display preferences id.</param> + /// <param name="userId">User Id.</param> + /// <param name="client">Client.</param> + /// <param name="displayPreferences">New Display Preferences object.</param> + /// <response code="204">Display preferences updated.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{displayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] + public ActionResult UpdateDisplayPreferences( + [FromRoute] string displayPreferencesId, + [FromQuery, BindRequired] string userId, + [FromQuery, BindRequired] string client, + [FromBody, BindRequired] DisplayPreferences displayPreferences) + { + _displayPreferencesRepository.SaveDisplayPreferences( + displayPreferences, + userId, + client, + CancellationToken.None); + + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 6a6e6a64a..0934a116a 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -1,7 +1,7 @@ -#pragma warning disable CA1801 - -using System; +using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using Jellyfin.Api.Constants; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -19,7 +19,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Filters controller. /// </summary> - [Authorize] + [Authorize(Policy = Policies.DefaultAuthorization)] public class FilterController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; @@ -137,6 +137,7 @@ namespace Jellyfin.Api.Controllers /// <returns>Query filters.</returns> [HttpGet("/Items/Filters2")] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "mediaTypes", Justification = "Imported from ServiceStack")] public ActionResult<QueryFilters> GetQueryFilters( [FromQuery] Guid? userId, [FromQuery] string? parentId, diff --git a/Jellyfin.Api/Controllers/ImageByNameController.cs b/Jellyfin.Api/Controllers/ImageByNameController.cs index 70f46ffa4..4800c0608 100644 --- a/Jellyfin.Api/Controllers/ImageByNameController.cs +++ b/Jellyfin.Api/Controllers/ImageByNameController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Mime; +using Jellyfin.Api.Constants; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -43,7 +44,7 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Retrieved list of images.</response> /// <returns>An <see cref="OkResult"/> containing the list of images.</returns> [HttpGet("General")] - [Authorize] + [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<ImageByNameInfo>> GetGeneralImages() { @@ -58,7 +59,7 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Image stream retrieved.</response> /// <response code="404">Image not found.</response> /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> - [HttpGet("General/{Name}/{Type}")] + [HttpGet("General/{name}/{type}")] [AllowAnonymous] [Produces(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] @@ -88,7 +89,7 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Retrieved list of images.</response> /// <returns>An <see cref="OkResult"/> containing the list of images.</returns> [HttpGet("Ratings")] - [Authorize] + [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<ImageByNameInfo>> GetRatingImages() { @@ -103,7 +104,7 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Image stream retrieved.</response> /// <response code="404">Image not found.</response> /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> - [HttpGet("Ratings/{Theme}/{Name}")] + [HttpGet("Ratings/{theme}/{name}")] [AllowAnonymous] [Produces(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] @@ -121,7 +122,7 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Image list retrieved.</response> /// <returns>An <see cref="OkResult"/> containing the list of images.</returns> [HttpGet("MediaInfo")] - [Authorize] + [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<ImageByNameInfo>> GetMediaInfoImages() { @@ -136,7 +137,7 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Image stream retrieved.</response> /// <response code="404">Image not found.</response> /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> - [HttpGet("MediaInfo/{Theme}/{Name}")] + [HttpGet("MediaInfo/{theme}/{name}")] [AllowAnonymous] [Produces(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs new file mode 100644 index 000000000..44709d0ee --- /dev/null +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Item lookup controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class ItemLookupController : BaseJellyfinApiController + { + private readonly IProviderManager _providerManager; + private readonly IServerApplicationPaths _appPaths; + private readonly IFileSystem _fileSystem; + private readonly ILibraryManager _libraryManager; + private readonly ILogger<ItemLookupController> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemLookupController"/> class. + /// </summary> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param> + public ItemLookupController( + IProviderManager providerManager, + IServerConfigurationManager serverConfigurationManager, + IFileSystem fileSystem, + ILibraryManager libraryManager, + ILogger<ItemLookupController> logger) + { + _providerManager = providerManager; + _appPaths = serverConfigurationManager.ApplicationPaths; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _logger = logger; + } + + /// <summary> + /// Get the item's external id info. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <response code="200">External id info retrieved.</response> + /// <response code="404">Item not found.</response> + /// <returns>List of external id info.</returns> + [HttpGet("/Items/{itemId}/ExternalIdInfos")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + return Ok(_providerManager.GetExternalIdInfos(item)); + } + + /// <summary> + /// Get movie remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Movie remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("/Items/RemoteSearch/Movie")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<MovieInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get trailer remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Trailer remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("/Items/RemoteSearch/Trailer")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<TrailerInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get music video remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music video remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("/Items/RemoteSearch/MusicVideo")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<MusicVideoInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get series remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Series remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("/Items/RemoteSearch/Series")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<SeriesInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get box set remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Box set remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("/Items/RemoteSearch/BoxSet")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<BoxSetInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get music artist remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music artist remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("/Items/RemoteSearch/MusicArtist")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<ArtistInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get music album remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music album remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("/Items/RemoteSearch/MusicAlbum")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<AlbumInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get person remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Person remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("/Items/RemoteSearch/Person")] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<PersonLookupInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Get book remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Book remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("/Items/RemoteSearch/Book")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, BindRequired] RemoteSearchQuery<BookInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Gets a remote image. + /// </summary> + /// <param name="imageUrl">The image url.</param> + /// <param name="providerName">The provider name.</param> + /// <response code="200">Remote image retrieved.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="FileStreamResult"/> containing the images file stream. + /// </returns> + [HttpGet("/Items/RemoteSearch/Image")] + public async Task<ActionResult> GetRemoteSearchImage( + [FromQuery, Required] string imageUrl, + [FromQuery, Required] string providerName) + { + var urlHash = imageUrl.GetMD5(); + var pointerCachePath = GetFullCachePath(urlHash.ToString()); + + try + { + var contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); + if (System.IO.File.Exists(contentPath)) + { + await using var fileStreamExisting = System.IO.File.OpenRead(pointerCachePath); + return new FileStreamResult(fileStreamExisting, MediaTypeNames.Application.Octet); + } + } + catch (FileNotFoundException) + { + // Means the file isn't cached yet + } + catch (IOException) + { + // Means the file isn't cached yet + } + + await DownloadImage(providerName, imageUrl, urlHash, pointerCachePath).ConfigureAwait(false); + + // Read the pointer file again + await using var fileStream = System.IO.File.OpenRead(pointerCachePath); + return new FileStreamResult(fileStream, MediaTypeNames.Application.Octet); + } + + /// <summary> + /// Applies search criteria to an item and refreshes metadata. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="searchResult">The remote search result.</param> + /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param> + /// <response code="204">Item metadata refreshed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="NoContentResult"/>. + /// </returns> + [HttpPost("/Items/RemoteSearch/Apply/{id}")] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task<ActionResult> ApplySearchCriteria( + [FromRoute] Guid itemId, + [FromBody, BindRequired] RemoteSearchResult searchResult, + [FromQuery] bool replaceAllImages = true) + { + var item = _libraryManager.GetItemById(itemId); + _logger.LogInformation( + "Setting provider id's to item {0}-{1}: {2}", + item.Id, + item.Name, + JsonSerializer.Serialize(searchResult.ProviderIds)); + + // Since the refresh process won't erase provider Ids, we need to set this explicitly now. + item.ProviderIds = searchResult.ProviderIds; + await _providerManager.RefreshFullItem( + item, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = replaceAllImages, + SearchResult = searchResult + }, CancellationToken.None).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Downloads the image. + /// </summary> + /// <param name="providerName">Name of the provider.</param> + /// <param name="url">The URL.</param> + /// <param name="urlHash">The URL hash.</param> + /// <param name="pointerCachePath">The pointer cache path.</param> + /// <returns>Task.</returns> + private async Task DownloadImage(string providerName, string url, Guid urlHash, string pointerCachePath) + { + var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).ConfigureAwait(false); + var ext = result.ContentType.Split('/').Last(); + var fullCachePath = GetFullCachePath(urlHash + "." + ext); + + Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); + await using (var stream = result.Content) + { + await using var fileStream = new FileStream( + fullCachePath, + FileMode.Create, + FileAccess.Write, + FileShare.Read, + IODefaults.FileStreamBufferSize, + true); + + await stream.CopyToAsync(fileStream).ConfigureAwait(false); + } + + Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); + await System.IO.File.WriteAllTextAsync(pointerCachePath, fullCachePath).ConfigureAwait(false); + } + + /// <summary> + /// Gets the full cache path. + /// </summary> + /// <param name="filename">The filename.</param> + /// <returns>System.String.</returns> + private string GetFullCachePath(string filename) + => Path.Combine(_appPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); + } +} diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index a1df22e41..e6cdf4edb 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -1,6 +1,7 @@ -#pragma warning disable CA1801 - +using System; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Jellyfin.Api.Constants; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; @@ -15,7 +16,7 @@ namespace Jellyfin.Api.Controllers /// </summary> /// [Authenticated] [Route("/Items")] - [Authorize] + [Authorize(Policy = Policies.DefaultAuthorization)] public class ItemRefreshController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; @@ -41,28 +42,29 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Refreshes metadata for an item. /// </summary> - /// <param name="id">Item id.</param> + /// <param name="itemId">Item id.</param> /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param> /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param> /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param> /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param> /// <param name="recursive">(Unused) Indicates if the refresh should occur recursively.</param> - /// <response code="200">Item metadata refresh queued.</response> + /// <response code="204">Item metadata refresh queued.</response> /// <response code="404">Item to refresh not found.</response> - /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> - [HttpPost("{Id}/Refresh")] + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("{itemId}/Refresh")] [Description("Refreshes metadata for an item.")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "recursive", Justification = "Imported from ServiceStack")] public ActionResult Post( - [FromRoute] string id, + [FromRoute] Guid itemId, [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None, [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None, [FromQuery] bool replaceAllMetadata = false, [FromQuery] bool replaceAllImages = false, [FromQuery] bool recursive = false) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); if (item == null) { return NotFound(); @@ -82,7 +84,7 @@ namespace Jellyfin.Api.Controllers }; _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); - return Ok(); + return NoContent(); } } } diff --git a/MediaBrowser.Api/ItemUpdateService.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 2db6d717a..384f250ec 100644 --- a/MediaBrowser.Api/ItemUpdateService.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -1,215 +1,99 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using Jellyfin.Api.Constants; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace MediaBrowser.Api +namespace Jellyfin.Api.Controllers { - [Route("/Items/{ItemId}", "POST", Summary = "Updates an item")] - public class UpdateItem : BaseItemDto, IReturnVoid - { - [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string ItemId { get; set; } - } - - [Route("/Items/{ItemId}/MetadataEditor", "GET", Summary = "Gets metadata editor info for an item")] - public class GetMetadataEditorInfo : IReturn<MetadataEditorInfo> - { - [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string ItemId { get; set; } - } - - [Route("/Items/{ItemId}/ContentType", "POST", Summary = "Updates an item's content type")] - public class UpdateItemContentType : IReturnVoid - { - [ApiMember(Name = "ItemId", Description = "The id of the item", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid ItemId { get; set; } - - [ApiMember(Name = "ContentType", Description = "The content type of the item", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ContentType { get; set; } - } - - [Authenticated(Roles = "admin")] - public class ItemUpdateService : BaseApiService + /// <summary> + /// Item update controller. + /// </summary> + [Authorize(Policy = Policies.RequiresElevation)] + public class ItemUpdateController : BaseJellyfinApiController { private readonly ILibraryManager _libraryManager; private readonly IProviderManager _providerManager; private readonly ILocalizationManager _localizationManager; private readonly IFileSystem _fileSystem; - - public ItemUpdateService( - ILogger<ItemUpdateService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemUpdateController"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public ItemUpdateController( IFileSystem fileSystem, ILibraryManager libraryManager, IProviderManager providerManager, - ILocalizationManager localizationManager) - : base(logger, serverConfigurationManager, httpResultFactory) + ILocalizationManager localizationManager, + IServerConfigurationManager serverConfigurationManager) { _libraryManager = libraryManager; _providerManager = providerManager; _localizationManager = localizationManager; _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; } - public object Get(GetMetadataEditorInfo request) + /// <summary> + /// Updates an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="request">The new item properties.</param> + /// <response code="204">Item updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("/Items/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateItem([FromRoute] Guid itemId, [FromBody, BindRequired] BaseItemDto request) { - var item = _libraryManager.GetItemById(request.ItemId); - - var info = new MetadataEditorInfo - { - ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(), - ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), - Countries = _localizationManager.GetCountries().ToArray(), - Cultures = _localizationManager.GetCultures().ToArray() - }; - - if (!item.IsVirtualItem && !(item is ICollectionFolder) && !(item is UserView) && !(item is AggregateFolder) && !(item is LiveTvChannel) && !(item is IItemByName) && - item.SourceType == SourceType.Library) - { - var inheritedContentType = _libraryManager.GetInheritedContentType(item); - var configuredContentType = _libraryManager.GetConfiguredContentType(item); - - if (string.IsNullOrWhiteSpace(inheritedContentType) || !string.IsNullOrWhiteSpace(configuredContentType)) - { - info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); - info.ContentType = configuredContentType; - - if (string.IsNullOrWhiteSpace(inheritedContentType) || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) - { - info.ContentTypeOptions = info.ContentTypeOptions - .Where(i => string.IsNullOrWhiteSpace(i.Value) || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - } - } - - return ToOptimizedResult(info); - } - - public void Post(UpdateItemContentType request) - { - var item = _libraryManager.GetItemById(request.ItemId); - var path = item.ContainingFolderPath; - - var types = ServerConfigurationManager.Configuration.ContentTypes - .Where(i => !string.IsNullOrWhiteSpace(i.Name)) - .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - if (!string.IsNullOrWhiteSpace(request.ContentType)) - { - types.Add(new NameValuePair - { - Name = path, - Value = request.ContentType - }); - } - - ServerConfigurationManager.Configuration.ContentTypes = types.ToArray(); - ServerConfigurationManager.SaveConfiguration(); - } - - private List<NameValuePair> GetContentTypeOptions(bool isForItem) - { - var list = new List<NameValuePair>(); - - if (isForItem) - { - list.Add(new NameValuePair - { - Name = "Inherit", - Value = "" - }); - } - - list.Add(new NameValuePair - { - Name = "Movies", - Value = "movies" - }); - list.Add(new NameValuePair - { - Name = "Music", - Value = "music" - }); - list.Add(new NameValuePair - { - Name = "Shows", - Value = "tvshows" - }); - - if (!isForItem) + var item = _libraryManager.GetItemById(itemId); + if (item == null) { - list.Add(new NameValuePair - { - Name = "Books", - Value = "books" - }); + return NotFound(); } - list.Add(new NameValuePair - { - Name = "HomeVideos", - Value = "homevideos" - }); - list.Add(new NameValuePair - { - Name = "MusicVideos", - Value = "musicvideos" - }); - list.Add(new NameValuePair - { - Name = "Photos", - Value = "photos" - }); - - if (!isForItem) - { - list.Add(new NameValuePair - { - Name = "MixedContent", - Value = "" - }); - } - - foreach (var val in list) - { - val.Name = _localizationManager.GetLocalizedString(val.Name); - } - - return list; - } - - public void Post(UpdateItem request) - { - var item = _libraryManager.GetItemById(request.ItemId); - var newLockData = request.LockData ?? false; var isLockedChanged = item.IsLocked != newLockData; var series = item as Series; - var displayOrderChanged = series != null && !string.Equals(series.DisplayOrder ?? string.Empty, request.DisplayOrder ?? string.Empty, StringComparison.OrdinalIgnoreCase); + var displayOrderChanged = series != null && !string.Equals( + series.DisplayOrder ?? string.Empty, + request.DisplayOrder ?? string.Empty, + StringComparison.OrdinalIgnoreCase); // Do this first so that metadata savers can pull the updates from the database. if (request.People != null) { - _libraryManager.UpdatePeople(item, request.People.Select(x => new PersonInfo { Name = x.Name, Role = x.Role, Type = x.Type }).ToList()); + _libraryManager.UpdatePeople( + item, + request.People.Select(x => new PersonInfo + { + Name = x.Name, + Role = x.Role, + Type = x.Type + }).ToList()); } UpdateItem(request, item); @@ -232,7 +116,7 @@ namespace MediaBrowser.Api if (displayOrderChanged) { _providerManager.QueueRefresh( - series.Id, + series!.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh, @@ -241,11 +125,101 @@ namespace MediaBrowser.Api }, RefreshPriority.High); } + + return NoContent(); } - private DateTime NormalizeDateTime(DateTime val) + /// <summary> + /// Gets metadata editor info for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">Item metadata editor returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpGet("/Items/{itemId}/MetadataEditor")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute] Guid itemId) { - return DateTime.SpecifyKind(val, DateTimeKind.Utc); + var item = _libraryManager.GetItemById(itemId); + + var info = new MetadataEditorInfo + { + ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(), + ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), + Countries = _localizationManager.GetCountries().ToArray(), + Cultures = _localizationManager.GetCultures().ToArray() + }; + + if (!item.IsVirtualItem + && !(item is ICollectionFolder) + && !(item is UserView) + && !(item is AggregateFolder) + && !(item is LiveTvChannel) + && !(item is IItemByName) + && item.SourceType == SourceType.Library) + { + var inheritedContentType = _libraryManager.GetInheritedContentType(item); + var configuredContentType = _libraryManager.GetConfiguredContentType(item); + + if (string.IsNullOrWhiteSpace(inheritedContentType) || + !string.IsNullOrWhiteSpace(configuredContentType)) + { + info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); + info.ContentType = configuredContentType; + + if (string.IsNullOrWhiteSpace(inheritedContentType) + || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) + { + info.ContentTypeOptions = info.ContentTypeOptions + .Where(i => string.IsNullOrWhiteSpace(i.Value) + || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + } + } + + return info; + } + + /// <summary> + /// Updates an item's content type. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="contentType">The content type of the item.</param> + /// <response code="204">Item content type updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("/Items/{itemId}/ContentType")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateItemContentType([FromRoute] Guid itemId, [FromQuery, BindRequired] string contentType) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + var path = item.ContainingFolderPath; + + var types = _serverConfigurationManager.Configuration.ContentTypes + .Where(i => !string.IsNullOrWhiteSpace(i.Name)) + .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (!string.IsNullOrWhiteSpace(contentType)) + { + types.Add(new NameValuePair + { + Name = path, + Value = contentType + }); + } + + _serverConfigurationManager.Configuration.ContentTypes = types.ToArray(); + _serverConfigurationManager.SaveConfiguration(); + return NoContent(); } private void UpdateItem(BaseItemDto request, BaseItem item) @@ -361,24 +335,25 @@ namespace MediaBrowser.Api } } - if (item is Audio song) - { - song.Album = request.Album; - } - - if (item is MusicVideo musicVideo) + switch (item) { - musicVideo.Album = request.Album; - } + case Audio song: + song.Album = request.Album; + break; + case MusicVideo musicVideo: + musicVideo.Album = request.Album; + break; + case Series series: + { + series.Status = GetSeriesStatus(request); - if (item is Series series) - { - series.Status = GetSeriesStatus(request); + if (request.AirDays != null) + { + series.AirDays = request.AirDays; + series.AirTime = request.AirTime; + } - if (request.AirDays != null) - { - series.AirDays = request.AirDays; - series.AirTime = request.AirTime; + break; } } } @@ -392,5 +367,81 @@ namespace MediaBrowser.Api return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true); } + + private DateTime NormalizeDateTime(DateTime val) + { + return DateTime.SpecifyKind(val, DateTimeKind.Utc); + } + + private List<NameValuePair> GetContentTypeOptions(bool isForItem) + { + var list = new List<NameValuePair>(); + + if (isForItem) + { + list.Add(new NameValuePair + { + Name = "Inherit", + Value = string.Empty + }); + } + + list.Add(new NameValuePair + { + Name = "Movies", + Value = "movies" + }); + list.Add(new NameValuePair + { + Name = "Music", + Value = "music" + }); + list.Add(new NameValuePair + { + Name = "Shows", + Value = "tvshows" + }); + + if (!isForItem) + { + list.Add(new NameValuePair + { + Name = "Books", + Value = "books" + }); + } + + list.Add(new NameValuePair + { + Name = "HomeVideos", + Value = "homevideos" + }); + list.Add(new NameValuePair + { + Name = "MusicVideos", + Value = "musicvideos" + }); + list.Add(new NameValuePair + { + Name = "Photos", + Value = "photos" + }); + + if (!isForItem) + { + list.Add(new NameValuePair + { + Name = "MixedContent", + Value = string.Empty + }); + } + + foreach (var val in list) + { + val.Name = _localizationManager.GetLocalizedString(val.Name); + } + + return list; + } } } diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index ca2905b11..62c547409 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -1,7 +1,6 @@ -#pragma warning disable CA1801 - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -56,6 +55,7 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns> [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders([FromQuery] string userId) { return _libraryManager.GetVirtualFolders(true); diff --git a/Jellyfin.Api/Controllers/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs index a1f9b9e8f..f22636489 100644 --- a/Jellyfin.Api/Controllers/NotificationsController.cs +++ b/Jellyfin.Api/Controllers/NotificationsController.cs @@ -1,7 +1,6 @@ -#pragma warning disable CA1801 - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using Jellyfin.Api.Models.NotificationDtos; @@ -43,8 +42,12 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">An optional limit on the number of notifications returned.</param> /// <response code="200">Notifications returned.</response> /// <returns>An <see cref="OkResult"/> containing a list of notifications.</returns> - [HttpGet("{UserID}")] + [HttpGet("{userId}")] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isRead", 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<NotificationResultDto> GetNotifications( [FromRoute] string userId, [FromQuery] bool? isRead, @@ -60,8 +63,9 @@ namespace Jellyfin.Api.Controllers /// <param name="userId">The user's ID.</param> /// <response code="200">Summary of user's notifications returned.</response> /// <returns>An <cref see="OkResult"/> containing a summary of the users notifications.</returns> - [HttpGet("{UserID}/Summary")] + [HttpGet("{userId}/Summary")] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] public ActionResult<NotificationsSummaryDto> GetNotificationsSummary( [FromRoute] string userId) { @@ -134,8 +138,10 @@ namespace Jellyfin.Api.Controllers /// <param name="ids">A comma-separated list of the IDs of notifications which should be set as read.</param> /// <response code="204">Notifications set as read.</response> /// <returns>A <cref see="NoContentResult"/>.</returns> - [HttpPost("{UserID}/Read")] + [HttpPost("{userId}/Read")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "ids", Justification = "Imported from ServiceStack")] public ActionResult SetRead( [FromRoute] string userId, [FromQuery] string ids) @@ -150,8 +156,10 @@ namespace Jellyfin.Api.Controllers /// <param name="ids">A comma-separated list of the IDs of notifications which should be set as unread.</param> /// <response code="204">Notifications set as unread.</response> /// <returns>A <cref see="NoContentResult"/>.</returns> - [HttpPost("{UserID}/Unread")] + [HttpPost("{userId}/Unread")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "ids", Justification = "Imported from ServiceStack")] public ActionResult SetUnread( [FromRoute] string userId, [FromQuery] string ids) diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 943c23f8e..486575d23 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -35,9 +35,10 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="name">The name of the package.</param> /// <param name="assemblyGuid">The GUID of the associated assembly.</param> + /// <response code="200">Package retrieved.</response> /// <returns>A <see cref="PackageInfo"/> containing package information.</returns> - [HttpGet("/{Name}")] - [ProducesResponseType(typeof(PackageInfo), StatusCodes.Status200OK)] + [HttpGet("/{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<PackageInfo>> GetPackageInfo( [FromRoute] [Required] string name, [FromQuery] string? assemblyGuid) @@ -54,9 +55,10 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets available packages. /// </summary> + /// <response code="200">Available packages returned.</response> /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns> [HttpGet] - [ProducesResponseType(typeof(PackageInfo[]), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task<IEnumerable<PackageInfo>> GetPackages() { IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); @@ -73,7 +75,7 @@ namespace Jellyfin.Api.Controllers /// <response code="204">Package found.</response> /// <response code="404">Package not found.</response> /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns> - [HttpPost("/Installed/{Name}")] + [HttpPost("/Installed/{name}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [Authorize(Policy = Policies.RequiresElevation)] @@ -102,17 +104,16 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Cancels a package installation. /// </summary> - /// <param name="id">Installation Id.</param> + /// <param name="packageId">Installation Id.</param> /// <response code="204">Installation cancelled.</response> /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns> - [HttpDelete("/Installing/{id}")] + [HttpDelete("/Installing/{packageId}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] public IActionResult CancelPackageInstallation( - [FromRoute] [Required] string id) + [FromRoute] [Required] Guid packageId) { - _installationManager.CancelInstallation(new Guid(id)); - + _installationManager.CancelInstallation(packageId); return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs new file mode 100644 index 000000000..2dc0d2dc7 --- /dev/null +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -0,0 +1,199 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.PlaylistDtos; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Playlists; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// Playlists controller. + /// </summary> + [Authorize(Policy = Policies.DefaultAuthorization)] + public class PlaylistsController : BaseJellyfinApiController + { + private readonly IPlaylistManager _playlistManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="PlaylistsController"/> class. + /// </summary> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public PlaylistsController( + IDtoService dtoService, + IPlaylistManager playlistManager, + IUserManager userManager, + ILibraryManager libraryManager) + { + _dtoService = dtoService; + _playlistManager = playlistManager; + _userManager = userManager; + _libraryManager = libraryManager; + } + + /// <summary> + /// Creates a new playlist. + /// </summary> + /// <param name="createPlaylistRequest">The create playlist payload.</param> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist. + /// The task result contains an <see cref="OkResult"/> indicating success. + /// </returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( + [FromBody, BindRequired] CreatePlaylistDto createPlaylistRequest) + { + Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids); + var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest + { + Name = createPlaylistRequest.Name, + ItemIdList = idGuidArray, + UserId = createPlaylistRequest.UserId, + MediaType = createPlaylistRequest.MediaType + }).ConfigureAwait(false); + + return result; + } + + /// <summary> + /// Adds items to a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="ids">Item id, comma delimited.</param> + /// <param name="userId">The userId.</param> + /// <response code="204">Items added to playlist.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddToPlaylist( + [FromRoute] string playlistId, + [FromQuery] string ids, + [FromQuery] Guid userId) + { + _playlistManager.AddToPlaylist(playlistId, RequestHelpers.GetGuids(ids), userId); + return NoContent(); + } + + /// <summary> + /// Moves a playlist item. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="newIndex">The new index.</param> + /// <response code="204">Item moved to new index.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult MoveItem( + [FromRoute] string playlistId, + [FromRoute] string itemId, + [FromRoute] int newIndex) + { + _playlistManager.MoveItem(playlistId, itemId, newIndex); + return NoContent(); + } + + /// <summary> + /// Removes items from a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="entryIds">The item ids, comma delimited.</param> + /// <response code="204">Items removed.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpDelete("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveFromPlaylist([FromRoute] string playlistId, [FromQuery] string entryIds) + { + _playlistManager.RemoveFromPlaylist(playlistId, RequestHelpers.Split(entryIds, ',', true)); + return NoContent(); + } + + /// <summary> + /// Gets the original items of a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="userId">User id.</param> + /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Original playlist returned.</response> + /// <response code="404">Playlist not found.</response> + /// <returns>The original playlist items.</returns> + [HttpGet("{playlistId}/Items")] + public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems( + [FromRoute] Guid playlistId, + [FromRoute] Guid userId, + [FromRoute] int? startIndex, + [FromRoute] int? limit, + [FromRoute] string fields, + [FromRoute] bool? enableImages, + [FromRoute] bool? enableUserData, + [FromRoute] int? imageTypeLimit, + [FromRoute] string enableImageTypes) + { + var playlist = (Playlist)_libraryManager.GetItemById(playlistId); + if (playlist == null) + { + return NotFound(); + } + + var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null; + + var items = playlist.GetManageableItems().ToArray(); + + var count = items.Length; + + if (startIndex.HasValue) + { + items = items.Skip(startIndex.Value).ToArray(); + } + + if (limit.HasValue) + { + items = items.Take(limit.Value).ToArray(); + } + + var dtoOptions = new DtoOptions() + .AddItemFields(fields) + .AddClientFields(Request) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); + + for (int index = 0; index < dtos.Count; index++) + { + dtos[index].PlaylistItemId = items[index].Item1.Id; + } + + var result = new QueryResult<BaseItemDto> + { + Items = dtos, + TotalRecordCount = count + }; + + return result; + } + } +} diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index fdb2f4c35..979d40119 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -1,7 +1,6 @@ -#pragma warning disable CA1801 - -using System; +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -12,6 +11,7 @@ using MediaBrowser.Common.Plugins; using MediaBrowser.Common.Updates; using MediaBrowser.Model.Plugins; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -20,7 +20,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Plugins controller. /// </summary> - [Authorize] + [Authorize(Policy = Policies.DefaultAuthorization)] public class PluginsController : BaseJellyfinApiController { private readonly IApplicationHost _appHost; @@ -46,6 +46,8 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Installed plugins returned.</response> /// <returns>List of currently installed plugins.</returns> [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isAppStoreEnabled", Justification = "Imported from ServiceStack")] public ActionResult<IEnumerable<PluginInfo>> GetPlugins([FromRoute] bool? isAppStoreEnabled) { return Ok(_appHost.Plugins.OrderBy(p => p.Name).Select(p => p.GetPluginInfo())); @@ -55,11 +57,13 @@ namespace Jellyfin.Api.Controllers /// Uninstalls a plugin. /// </summary> /// <param name="pluginId">Plugin id.</param> - /// <response code="200">Plugin uninstalled.</response> + /// <response code="204">Plugin uninstalled.</response> /// <response code="404">Plugin not found.</response> - /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> [HttpDelete("{pluginId}")] [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UninstallPlugin([FromRoute] Guid pluginId) { var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId); @@ -69,7 +73,7 @@ namespace Jellyfin.Api.Controllers } _installationManager.UninstallPlugin(plugin); - return Ok(); + return NoContent(); } /// <summary> @@ -80,6 +84,8 @@ namespace Jellyfin.Api.Controllers /// <response code="404">Plugin not found or plugin configuration not found.</response> /// <returns>Plugin configuration.</returns> [HttpGet("{pluginId}/Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute] Guid pluginId) { if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin)) @@ -97,14 +103,16 @@ namespace Jellyfin.Api.Controllers /// Accepts plugin configuration as JSON body. /// </remarks> /// <param name="pluginId">Plugin id.</param> - /// <response code="200">Plugin configuration updated.</response> - /// <response code="200">Plugin not found or plugin does not have configuration.</response> + /// <response code="204">Plugin configuration updated.</response> + /// <response code="404">Plugin not found or plugin does not have configuration.</response> /// <returns> /// A <see cref="Task" /> that represents the asynchronous operation to update plugin configuration. - /// The task result contains an <see cref="OkResult"/> indicating success, or <see cref="NotFoundResult"/> + /// The task result contains an <see cref="NoContentResult"/> indicating success, or <see cref="NotFoundResult"/> /// when plugin not found or plugin doesn't have configuration. /// </returns> [HttpPost("{pluginId}/Configuration")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> UpdatePluginConfiguration([FromRoute] Guid pluginId) { if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin)) @@ -116,7 +124,7 @@ namespace Jellyfin.Api.Controllers .ConfigureAwait(false); plugin.UpdateConfiguration(configuration); - return Ok(); + return NoContent(); } /// <summary> @@ -126,6 +134,7 @@ namespace Jellyfin.Api.Controllers /// <returns>Plugin security info.</returns> [Obsolete("This endpoint should not be used.")] [HttpGet("SecurityInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<PluginSecurityInfo> GetPluginSecurityInfo() { return new PluginSecurityInfo @@ -139,14 +148,15 @@ namespace Jellyfin.Api.Controllers /// Updates plugin security info. /// </summary> /// <param name="pluginSecurityInfo">Plugin security info.</param> - /// <response code="200">Plugin security info updated.</response> - /// <returns>An <see cref="OkResult"/>.</returns> + /// <response code="204">Plugin security info updated.</response> + /// <returns>An <see cref="NoContentResult"/>.</returns> [Obsolete("This endpoint should not be used.")] [HttpPost("SecurityInfo")] [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdatePluginSecurityInfo([FromBody, BindRequired] PluginSecurityInfo pluginSecurityInfo) { - return Ok(); + return NoContent(); } /// <summary> @@ -157,6 +167,7 @@ namespace Jellyfin.Api.Controllers /// <returns>Mb registration record.</returns> [Obsolete("This endpoint should not be used.")] [HttpPost("RegistrationRecords/{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string name) { return new MBRegistrationRecord @@ -178,6 +189,7 @@ namespace Jellyfin.Api.Controllers /// <exception cref="NotImplementedException">This endpoint is not implemented.</exception> [Obsolete("Paid plugins are not supported")] [HttpGet("/Registrations/{name}")] + [ProducesResponseType(StatusCodes.Status501NotImplemented)] public ActionResult GetRegistration([FromRoute] string name) { // TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins, diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index 80983ee64..a0d14be7a 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Constants; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; @@ -25,7 +26,7 @@ namespace Jellyfin.Api.Controllers /// Remote Images Controller. /// </summary> [Route("Images")] - [Authorize] + [Authorize(Policy = Policies.DefaultAuthorization)] public class RemoteImageController : BaseJellyfinApiController { private readonly IProviderManager _providerManager; @@ -55,7 +56,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets available remote images for an item. /// </summary> - /// <param name="id">Item Id.</param> + /// <param name="itemId">Item Id.</param> /// <param name="type">The image type.</param> /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> @@ -64,18 +65,18 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Remote Images returned.</response> /// <response code="404">Item not found.</response> /// <returns>Remote Image Result.</returns> - [HttpGet("{Id}/RemoteImages")] + [HttpGet("{itemId}/RemoteImages")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<RemoteImageResult>> GetRemoteImages( - [FromRoute] string id, + [FromRoute] Guid itemId, [FromQuery] ImageType? type, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string providerName, [FromQuery] bool includeAllLanguages) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); if (item == null) { return NotFound(); @@ -123,16 +124,16 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets available remote image providers for an item. /// </summary> - /// <param name="id">Item Id.</param> + /// <param name="itemId">Item Id.</param> /// <response code="200">Returned remote image providers.</response> /// <response code="404">Item not found.</response> /// <returns>List of remote image providers.</returns> - [HttpGet("{Id}/RemoteImages/Providers")] + [HttpGet("{itemId}/RemoteImages/Providers")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute] string id) + public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute] Guid itemId) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); if (item == null) { return NotFound(); @@ -195,21 +196,21 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Downloads a remote image for an item. /// </summary> - /// <param name="id">Item Id.</param> + /// <param name="itemId">Item Id.</param> /// <param name="type">The image type.</param> /// <param name="imageUrl">The image url.</param> - /// <response code="200">Remote image downloaded.</response> + /// <response code="204">Remote image downloaded.</response> /// <response code="404">Remote image not found.</response> /// <returns>Download status.</returns> - [HttpPost("{Id}/RemoteImages/Download")] - [ProducesResponseType(StatusCodes.Status200OK)] + [HttpPost("{itemId}/RemoteImages/Download")] + [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> DownloadRemoteImage( - [FromRoute] string id, + [FromRoute] Guid itemId, [FromQuery, BindRequired] ImageType type, [FromQuery] string imageUrl) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); if (item == null) { return NotFound(); @@ -219,7 +220,7 @@ namespace Jellyfin.Api.Controllers .ConfigureAwait(false); item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None); - return Ok(); + return NoContent(); } /// <summary> diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs new file mode 100644 index 000000000..39da4178d --- /dev/null +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -0,0 +1,475 @@ +#pragma warning disable CA1801 + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Session; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The session controller. + /// </summary> + public class SessionController : BaseJellyfinApiController + { + private readonly ISessionManager _sessionManager; + private readonly IUserManager _userManager; + private readonly IAuthorizationContext _authContext; + private readonly IDeviceManager _deviceManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SessionController"/> class. + /// </summary> + /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> + /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> + /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> + public SessionController( + ISessionManager sessionManager, + IUserManager userManager, + IAuthorizationContext authContext, + IDeviceManager deviceManager) + { + _sessionManager = sessionManager; + _userManager = userManager; + _authContext = authContext; + _deviceManager = deviceManager; + } + + /// <summary> + /// Gets a list of sessions. + /// </summary> + /// <param name="controllableByUserId">Filter by sessions that a given user is allowed to remote control.</param> + /// <param name="deviceId">Filter by device Id.</param> + /// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param> + /// <response code="200">List of sessions returned.</response> + /// <returns>An <see cref="IEnumerable{SessionInfo}"/> with the available sessions.</returns> + [HttpGet("/Sessions")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<SessionInfo>> GetSessions( + [FromQuery] Guid controllableByUserId, + [FromQuery] string deviceId, + [FromQuery] int? activeWithinSeconds) + { + var result = _sessionManager.Sessions; + + if (!string.IsNullOrEmpty(deviceId)) + { + result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); + } + + if (!controllableByUserId.Equals(Guid.Empty)) + { + result = result.Where(i => i.SupportsRemoteControl); + + var user = _userManager.GetUserById(controllableByUserId); + + if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) + { + result = result.Where(i => i.UserId.Equals(Guid.Empty) || i.ContainsUser(controllableByUserId)); + } + + if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) + { + result = result.Where(i => !i.UserId.Equals(Guid.Empty)); + } + + if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0) + { + var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value); + result = result.Where(i => i.LastActivityDate >= minActiveDate); + } + + result = result.Where(i => + { + if (!string.IsNullOrWhiteSpace(i.DeviceId)) + { + if (!_deviceManager.CanAccessDevice(user, i.DeviceId)) + { + return false; + } + } + + return true; + }); + } + + return Ok(result); + } + + /// <summary> + /// Instructs a session to browse to an item or view. + /// </summary> + /// <param name="sessionId">The session Id.</param> + /// <param name="itemType">The type of item to browse to.</param> + /// <param name="itemId">The Id of the item.</param> + /// <param name="itemName">The name of the item.</param> + /// <response code="204">Instruction sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/{sessionId}/Viewing")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DisplayContent( + [FromRoute] string sessionId, + [FromQuery] string itemType, + [FromQuery] string itemId, + [FromQuery] string itemName) + { + var command = new BrowseRequest + { + ItemId = itemId, + ItemName = itemName, + ItemType = itemType + }; + + _sessionManager.SendBrowseCommand( + RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, + sessionId, + command, + CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Instructs a session to play an item. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="itemIds">The ids of the items to play, comma delimited.</param> + /// <param name="startPositionTicks">The starting position of the first item.</param> + /// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param> + /// <param name="playRequest">The <see cref="PlayRequest"/>.</param> + /// <response code="204">Instruction sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/{sessionId}/Playing")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult Play( + [FromRoute] string sessionId, + [FromQuery] Guid[] itemIds, + [FromQuery] long? startPositionTicks, + [FromQuery] PlayCommand playCommand, + [FromBody, Required] PlayRequest playRequest) + { + if (playRequest == null) + { + throw new ArgumentException("Request Body may not be null"); + } + + playRequest.ItemIds = itemIds; + playRequest.StartPositionTicks = startPositionTicks; + playRequest.PlayCommand = playCommand; + + _sessionManager.SendPlayCommand( + RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, + sessionId, + playRequest, + CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Issues a playstate command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="playstateRequest">The <see cref="PlaystateRequest"/>.</param> + /// <response code="204">Playstate command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/{sessionId}/Playing/{command}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SendPlaystateCommand( + [FromRoute] string sessionId, + [FromBody] PlaystateRequest playstateRequest) + { + _sessionManager.SendPlaystateCommand( + RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, + sessionId, + playstateRequest, + CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Issues a system command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The command to send.</param> + /// <response code="204">System command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/{sessionId}/System/{command}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SendSystemCommand( + [FromRoute] string sessionId, + [FromRoute] string command) + { + var name = command; + if (Enum.TryParse(name, true, out GeneralCommandType commandType)) + { + name = commandType.ToString(); + } + + var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + var generalCommand = new GeneralCommand + { + Name = name, + ControllingUserId = currentSession.UserId + }; + + _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Issues a general command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The command to send.</param> + /// <response code="204">General command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/{sessionId}/Command/{Command}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SendGeneralCommand( + [FromRoute] string sessionId, + [FromRoute] string command) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + + var generalCommand = new GeneralCommand + { + Name = command, + ControllingUserId = currentSession.UserId + }; + + _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Issues a full general command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The <see cref="GeneralCommand"/>.</param> + /// <response code="204">Full general command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/{sessionId}/Command")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SendFullGeneralCommand( + [FromRoute] string sessionId, + [FromBody, Required] GeneralCommand command) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request); + + if (command == null) + { + throw new ArgumentException("Request body may not be null"); + } + + command.ControllingUserId = currentSession.UserId; + + _sessionManager.SendGeneralCommand( + currentSession.Id, + sessionId, + command, + CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Issues a command to a client to display a message to the user. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="text">The message test.</param> + /// <param name="header">The message header.</param> + /// <param name="timeoutMs">The message timeout. If omitted the user will have to confirm viewing the message.</param> + /// <response code="204">Message sent.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/{sessionId}/Message")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SendMessageCommand( + [FromRoute] string sessionId, + [FromQuery] string text, + [FromQuery] string header, + [FromQuery] long? timeoutMs) + { + var command = new MessageCommand + { + Header = string.IsNullOrEmpty(header) ? "Message from Server" : header, + TimeoutMs = timeoutMs, + Text = text + }; + + _sessionManager.SendMessageCommand(RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id, sessionId, command, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Adds an additional user to a session. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="userId">The user id.</param> + /// <response code="204">User added to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/{sessionId}/User/{userId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddUserToSession( + [FromRoute] string sessionId, + [FromRoute] Guid userId) + { + _sessionManager.AddAdditionalUser(sessionId, userId); + return NoContent(); + } + + /// <summary> + /// Removes an additional user from a session. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="userId">The user id.</param> + /// <response code="204">User removed from session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("/Sessions/{sessionId}/User/{userId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveUserFromSession( + [FromRoute] string sessionId, + [FromRoute] Guid userId) + { + _sessionManager.RemoveAdditionalUser(sessionId, userId); + return NoContent(); + } + + /// <summary> + /// Updates capabilities for a device. + /// </summary> + /// <param name="id">The session id.</param> + /// <param name="playableMediaTypes">A list of playable media types, comma delimited. Audio, Video, Book, Photo.</param> + /// <param name="supportedCommands">A list of supported remote control commands, comma delimited.</param> + /// <param name="supportsMediaControl">Determines whether media can be played remotely..</param> + /// <param name="supportsSync">Determines whether sync is supported.</param> + /// <param name="supportsPersistentIdentifier">Determines whether the device supports a unique identifier.</param> + /// <response code="204">Capabilities posted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/Capabilities")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostCapabilities( + [FromQuery] string id, + [FromQuery] string playableMediaTypes, + [FromQuery] string supportedCommands, + [FromQuery] bool supportsMediaControl, + [FromQuery] bool supportsSync, + [FromQuery] bool supportsPersistentIdentifier = true) + { + if (string.IsNullOrWhiteSpace(id)) + { + id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + } + + _sessionManager.ReportCapabilities(id, new ClientCapabilities + { + PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true), + SupportedCommands = RequestHelpers.Split(supportedCommands, ',', true), + SupportsMediaControl = supportsMediaControl, + SupportsSync = supportsSync, + SupportsPersistentIdentifier = supportsPersistentIdentifier + }); + return NoContent(); + } + + /// <summary> + /// Updates capabilities for a device. + /// </summary> + /// <param name="id">The session id.</param> + /// <param name="capabilities">The <see cref="ClientCapabilities"/>.</param> + /// <response code="204">Capabilities updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/Capabilities/Full")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostFullCapabilities( + [FromQuery] string id, + [FromBody, Required] ClientCapabilities capabilities) + { + if (string.IsNullOrWhiteSpace(id)) + { + id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + } + + _sessionManager.ReportCapabilities(id, capabilities); + + return NoContent(); + } + + /// <summary> + /// Reports that a session is viewing an item. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="itemId">The item id.</param> + /// <response code="204">Session reported to server.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/Viewing")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult ReportViewing( + [FromQuery] string sessionId, + [FromQuery] string itemId) + { + string session = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; + + _sessionManager.ReportNowViewingItem(session, itemId); + return NoContent(); + } + + /// <summary> + /// Reports that a session has ended. + /// </summary> + /// <response code="204">Session end reported to server.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("/Sessions/Logout")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult ReportSessionEnded() + { + AuthorizationInfo auth = _authContext.GetAuthorizationInfo(Request); + + _sessionManager.Logout(auth.Token); + return NoContent(); + } + + /// <summary> + /// Get all auth providers. + /// </summary> + /// <response code="200">Auth providers retrieved.</response> + /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns> + [HttpGet("/Auth/Providers")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders() + { + return _userManager.GetAuthenticationProviders(); + } + + /// <summary> + /// Get all password reset providers. + /// </summary> + /// <response code="200">Password reset providers retrieved.</response> + /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns> + [HttpGet("/Auto/PasswordResetProviders")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders() + { + return _userManager.GetPasswordResetProviders(); + } + } +} diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index 69b83379d..95cc39524 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -1,8 +1,7 @@ -#pragma warning disable CA1801 - using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -76,20 +75,20 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Deletes an external subtitle file. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="itemId">The item id.</param> /// <param name="index">The index of the subtitle file.</param> /// <response code="204">Subtitle deleted.</response> /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("/Videos/{id}/Subtitles/{index}")] + [HttpDelete("/Videos/{itemId}/Subtitles/{index}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<Task> DeleteSubtitle( - [FromRoute] Guid id, + [FromRoute] Guid itemId, [FromRoute] int index) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); if (item == null) { @@ -103,20 +102,20 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Search remote subtitles. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="itemId">The item id.</param> /// <param name="language">The language of the subtitles.</param> /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param> /// <response code="200">Subtitles retrieved.</response> /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns> - [HttpGet("/Items/{id}/RemoteSearch/Subtitles/{language}")] + [HttpGet("/Items/{itemId}/RemoteSearch/Subtitles/{language}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( - [FromRoute] Guid id, + [FromRoute] Guid itemId, [FromRoute] string language, [FromQuery] bool? isPerfectMatch) { - var video = (Video)_libraryManager.GetItemById(id); + var video = (Video)_libraryManager.GetItemById(itemId); return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, CancellationToken.None).ConfigureAwait(false); } @@ -124,18 +123,18 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Downloads a remote subtitle. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="itemId">The item id.</param> /// <param name="subtitleId">The subtitle id.</param> /// <response code="204">Subtitle downloaded.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("/Items/{id}/RemoteSearch/Subtitles/{subtitleId}")] + [HttpPost("/Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> DownloadRemoteSubtitles( - [FromRoute] Guid id, + [FromRoute] Guid itemId, [FromRoute] string subtitleId) { - var video = (Video)_libraryManager.GetItemById(id); + var video = (Video)_libraryManager.GetItemById(itemId); try { @@ -172,28 +171,28 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets subtitles in a specified format. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="itemId">The item id.</param> /// <param name="mediaSourceId">The media source id.</param> /// <param name="index">The subtitle stream index.</param> /// <param name="format">The format of the returned subtitle.</param> - /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param> /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param> /// <response code="200">File returned.</response> /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> - [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")] - [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")] + [HttpGet("/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")] + [HttpGet("/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult> GetSubtitle( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromRoute, Required] string mediaSourceId, [FromRoute, Required] int index, [FromRoute, Required] string format, - [FromRoute] long startPositionTicks, [FromQuery] long? endPositionTicks, [FromQuery] bool copyTimestamps, - [FromQuery] bool addVttTimeMap) + [FromQuery] bool addVttTimeMap, + [FromRoute] long startPositionTicks = 0) { if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) { @@ -202,9 +201,9 @@ namespace Jellyfin.Api.Controllers if (string.IsNullOrEmpty(format)) { - var item = (Video)_libraryManager.GetItemById(id); + var item = (Video)_libraryManager.GetItemById(itemId); - var idString = id.ToString("N", CultureInfo.InvariantCulture); + var idString = itemId.ToString("N", CultureInfo.InvariantCulture); var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); @@ -217,7 +216,7 @@ namespace Jellyfin.Api.Controllers if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) { - await using Stream stream = await EncodeSubtitles(id, mediaSourceId, index, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); + await using Stream stream = await EncodeSubtitles(itemId, mediaSourceId, index, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); using var reader = new StreamReader(stream); var text = await reader.ReadToEndAsync().ConfigureAwait(false); @@ -229,7 +228,7 @@ namespace Jellyfin.Api.Controllers return File( await EncodeSubtitles( - id, + itemId, mediaSourceId, index, format, @@ -242,23 +241,23 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets an HLS subtitle playlist. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="itemId">The item id.</param> /// <param name="index">The subtitle stream index.</param> /// <param name="mediaSourceId">The media source id.</param> /// <param name="segmentLength">The subtitle segment length.</param> /// <response code="200">Subtitle playlist retrieved.</response> /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> - [HttpGet("/Videos/{id}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] + [HttpGet("/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] public async Task<ActionResult> GetSubtitlePlaylist( - [FromRoute] Guid id, - // TODO: 'int index' is never used: CA1801 is disabled + [FromRoute] Guid itemId, [FromRoute] int index, [FromRoute] string mediaSourceId, [FromQuery, Required] int segmentLength) { - var item = (Video)_libraryManager.GetItemById(id); + var item = (Video)_libraryManager.GetItemById(itemId); var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs new file mode 100644 index 000000000..e1a99a138 --- /dev/null +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -0,0 +1,87 @@ +using System; +using System.Linq; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The suggestions controller. + /// </summary> + public class SuggestionsController : BaseJellyfinApiController + { + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SuggestionsController"/> class. + /// </summary> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public SuggestionsController( + IDtoService dtoService, + IUserManager userManager, + ILibraryManager libraryManager) + { + _dtoService = dtoService; + _userManager = userManager; + _libraryManager = libraryManager; + } + + /// <summary> + /// Gets suggestions. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="mediaType">The media types.</param> + /// <param name="type">The type.</param> + /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param> + /// <param name="startIndex">Optional. The start index.</param> + /// <param name="limit">Optional. The limit.</param> + /// <response code="200">Suggestions returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns> + [HttpGet("/Users/{userId}/Suggestions")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( + [FromRoute] Guid userId, + [FromQuery] string? mediaType, + [FromQuery] string? type, + [FromQuery] bool enableTotalRecordCount, + [FromQuery] int? startIndex, + [FromQuery] int? limit) + { + var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null; + + var dtoOptions = new DtoOptions().AddClientFields(Request); + var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) + { + OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), + MediaTypes = RequestHelpers.Split(mediaType!, ',', true), + IncludeItemTypes = RequestHelpers.Split(type!, ',', true), + IsVirtualItem = false, + StartIndex = startIndex, + Limit = limit, + DtoOptions = dtoOptions, + EnableTotalRecordCount = enableTotalRecordCount, + Recursive = true + }); + + var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); + + return new QueryResult<BaseItemDto> + { + TotalRecordCount = result.TotalRecordCount, + Items = dtoList + }; + } + } +} diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs new file mode 100644 index 000000000..c1f417df5 --- /dev/null +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -0,0 +1,552 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.UserDtos; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Users; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// User controller. + /// </summary> + [Route("/Users")] + public class UserController : BaseJellyfinApiController + { + private readonly IUserManager _userManager; + private readonly ISessionManager _sessionManager; + private readonly INetworkManager _networkManager; + private readonly IDeviceManager _deviceManager; + private readonly IAuthorizationContext _authContext; + private readonly IServerConfigurationManager _config; + + /// <summary> + /// Initializes a new instance of the <see cref="UserController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public UserController( + IUserManager userManager, + ISessionManager sessionManager, + INetworkManager networkManager, + IDeviceManager deviceManager, + IAuthorizationContext authContext, + IServerConfigurationManager config) + { + _userManager = userManager; + _sessionManager = sessionManager; + _networkManager = networkManager; + _deviceManager = deviceManager; + _authContext = authContext; + _config = config; + } + + /// <summary> + /// Gets a list of users. + /// </summary> + /// <param name="isHidden">Optional filter by IsHidden=true or false.</param> + /// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param> + /// <param name="isGuest">Optional filter by IsGuest=true or false.</param> + /// <response code="200">Users returned.</response> + /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns> + [HttpGet] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isGuest", Justification = "Imported from ServiceStack")] + public ActionResult<IEnumerable<UserDto>> GetUsers( + [FromQuery] bool? isHidden, + [FromQuery] bool? isDisabled, + [FromQuery] bool? isGuest) + { + var users = Get(isHidden, isDisabled, false, false); + return Ok(users); + } + + /// <summary> + /// Gets a list of publicly visible users for display on a login screen. + /// </summary> + /// <response code="200">Public users returned.</response> + /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns> + [HttpGet("Public")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<UserDto>> GetPublicUsers() + { + // If the startup wizard hasn't been completed then just return all users + if (!_config.Configuration.IsStartupWizardCompleted) + { + return Ok(Get(false, false, false, false)); + } + + return Ok(Get(false, false, true, true)); + } + + /// <summary> + /// Gets a user by Id. + /// </summary> + /// <param name="userId">The user id.</param> + /// <response code="200">User returned.</response> + /// <response code="404">User not found.</response> + /// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns> + [HttpGet("{userId}")] + [Authorize(Policy = Policies.IgnoreSchedule)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<UserDto> GetUserById([FromRoute] Guid userId) + { + var user = _userManager.GetUserById(userId); + + if (user == null) + { + return NotFound("User not found"); + } + + var result = _userManager.GetUserDto(user, HttpContext.Connection.RemoteIpAddress.ToString()); + return result; + } + + /// <summary> + /// Deletes a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <response code="200">User deleted.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns> + [HttpDelete("{userId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteUser([FromRoute] Guid userId) + { + var user = _userManager.GetUserById(userId); + + if (user == null) + { + return NotFound("User not found"); + } + + _sessionManager.RevokeUserTokens(user.Id, null); + _userManager.DeleteUser(user); + return NoContent(); + } + + /// <summary> + /// Authenticates a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="pw">The password as plain text.</param> + /// <param name="password">The password sha1-hash.</param> + /// <response code="200">User authenticated.</response> + /// <response code="403">Sha1-hashed password only is not allowed.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns> + [HttpPost("{userId}/Authenticate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<AuthenticationResult>> AuthenticateUser( + [FromRoute, Required] Guid userId, + [FromQuery, BindRequired] string pw, + [FromQuery, BindRequired] string password) + { + var user = _userManager.GetUserById(userId); + + if (user == null) + { + return NotFound("User not found"); + } + + if (!string.IsNullOrEmpty(password) && string.IsNullOrEmpty(pw)) + { + return Forbid("Only sha1 password is not allowed."); + } + + // Password should always be null + AuthenticateUserByName request = new AuthenticateUserByName + { + Username = user.Username, + Password = null, + Pw = pw + }; + return await AuthenticateUserByName(request).ConfigureAwait(false); + } + + /// <summary> + /// Authenticates a user by name. + /// </summary> + /// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param> + /// <response code="200">User authenticated.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> + [HttpPost("AuthenticateByName")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, BindRequired] AuthenticateUserByName request) + { + var auth = _authContext.GetAuthorizationInfo(Request); + + try + { + var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest + { + App = auth.Client, + AppVersion = auth.Version, + DeviceId = auth.DeviceId, + DeviceName = auth.Device, + Password = request.Pw, + PasswordSha1 = request.Password, + RemoteEndPoint = HttpContext.Connection.RemoteIpAddress.ToString(), + Username = request.Username + }).ConfigureAwait(false); + + return result; + } + catch (SecurityException e) + { + // rethrow adding IP address to message + throw new SecurityException($"[{HttpContext.Connection.RemoteIpAddress}] {e.Message}", e); + } + } + + /// <summary> + /// Updates a user's password. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param> + /// <response code="200">Password successfully reset.</response> + /// <response code="403">User is not allowed to update the password.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> + [HttpPost("{userId}/Password")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdateUserPassword( + [FromRoute] Guid userId, + [FromBody] UpdateUserPassword request) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + { + return Forbid("User is not allowed to update the password."); + } + + var user = _userManager.GetUserById(userId); + + if (user == null) + { + return NotFound("User not found"); + } + + if (request.ResetPassword) + { + await _userManager.ResetPassword(user).ConfigureAwait(false); + } + else + { + var success = await _userManager.AuthenticateUser( + user.Username, + request.CurrentPw, + request.CurrentPw, + HttpContext.Connection.RemoteIpAddress.ToString(), + false).ConfigureAwait(false); + + if (success == null) + { + return Forbid("Invalid user or password entered."); + } + + await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); + + var currentToken = _authContext.GetAuthorizationInfo(Request).Token; + + _sessionManager.RevokeUserTokens(user.Id, currentToken); + } + + return NoContent(); + } + + /// <summary> + /// Updates a user's easy password. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param> + /// <response code="200">Password successfully reset.</response> + /// <response code="403">User is not allowed to update the password.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> + [HttpPost("{userId}/EasyPassword")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateUserEasyPassword( + [FromRoute] Guid userId, + [FromBody] UpdateUserEasyPassword request) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + { + return Forbid("User is not allowed to update the easy password."); + } + + var user = _userManager.GetUserById(userId); + + if (user == null) + { + return NotFound("User not found"); + } + + if (request.ResetPassword) + { + _userManager.ResetEasyPassword(user); + } + else + { + _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword); + } + + return NoContent(); + } + + /// <summary> + /// Updates a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="updateUser">The updated user model.</param> + /// <response code="204">User updated.</response> + /// <response code="400">User information was not supplied.</response> + /// <response code="403">User update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns> + [HttpPost("{userId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> UpdateUser( + [FromRoute] Guid userId, + [FromBody] UserDto updateUser) + { + if (updateUser == null) + { + return BadRequest(); + } + + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false)) + { + return Forbid("User update not allowed."); + } + + var user = _userManager.GetUserById(userId); + + if (string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) + { + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + _userManager.UpdateConfiguration(user.Id, updateUser.Configuration); + } + else + { + await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + _userManager.UpdateConfiguration(updateUser.Id, updateUser.Configuration); + } + + return NoContent(); + } + + /// <summary> + /// Updates a user policy. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="newPolicy">The new user policy.</param> + /// <response code="204">User policy updated.</response> + /// <response code="400">User policy was not supplied.</response> + /// <response code="403">User policy update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns> + [HttpPost("{userId}/Policy")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult UpdateUserPolicy( + [FromRoute] Guid userId, + [FromBody] UserPolicy newPolicy) + { + if (newPolicy == null) + { + return BadRequest(); + } + + var user = _userManager.GetUserById(userId); + + // If removing admin access + if (!(newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))) + { + if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) + { + return Forbid("There must be at least one user in the system with administrative access."); + } + } + + // If disabling + if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) + { + return Forbid("Administrators cannot be disabled."); + } + + // If disabling + if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) + { + if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) + { + return Forbid("There must be at least one enabled user in the system."); + } + + var currentToken = _authContext.GetAuthorizationInfo(Request).Token; + _sessionManager.RevokeUserTokens(user.Id, currentToken); + } + + _userManager.UpdatePolicy(userId, newPolicy); + + return NoContent(); + } + + /// <summary> + /// Updates a user configuration. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="userConfig">The new user configuration.</param> + /// <response code="204">User configuration updated.</response> + /// <response code="403">User configuration update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("{userId}/Configuration")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public ActionResult UpdateUserConfiguration( + [FromRoute] Guid userId, + [FromBody] UserConfiguration userConfig) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false)) + { + return Forbid("User configuration update not allowed"); + } + + _userManager.UpdateConfiguration(userId, userConfig); + + return NoContent(); + } + + /// <summary> + /// Creates a user. + /// </summary> + /// <param name="request">The create user by name request body.</param> + /// <response code="200">User created.</response> + /// <returns>An <see cref="UserDto"/> of the new user.</returns> + [HttpPost("/Users/New")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<UserDto>> CreateUserByName([FromBody] CreateUserByName request) + { + var newUser = _userManager.CreateUser(request.Name); + + // no need to authenticate password for new user + if (request.Password != null) + { + await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); + } + + var result = _userManager.GetUserDto(newUser, HttpContext.Connection.RemoteIpAddress.ToString()); + + return result; + } + + /// <summary> + /// Initiates the forgot password process for a local user. + /// </summary> + /// <param name="enteredUsername">The entered username.</param> + /// <response code="200">Password reset process started.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns> + [HttpPost("ForgotPassword")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody] string enteredUsername) + { + var isLocal = HttpContext.Connection.RemoteIpAddress.Equals(HttpContext.Connection.LocalIpAddress) + || _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString()); + + var result = await _userManager.StartForgotPasswordProcess(enteredUsername, isLocal).ConfigureAwait(false); + + return result; + } + + /// <summary> + /// Redeems a forgot password pin. + /// </summary> + /// <param name="pin">The pin.</param> + /// <response code="200">Pin reset process started.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> + [HttpPost("ForgotPassword/Pin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody] string pin) + { + var result = await _userManager.RedeemPasswordResetPin(pin).ConfigureAwait(false); + return result; + } + + private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + { + var users = _userManager.Users; + + if (isDisabled.HasValue) + { + users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value); + } + + if (isHidden.HasValue) + { + users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value); + } + + if (filterByDevice) + { + var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId; + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); + } + } + + if (filterByNetwork) + { + if (!_networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString())) + { + users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); + } + } + + var result = users + .OrderBy(u => u.Username) + .Select(i => _userManager.GetUserDto(i, HttpContext.Connection.RemoteIpAddress.ToString())); + + return result; + } + } +} diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index 2528fd75d..943ba8af3 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -44,7 +44,7 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Attachment retrieved.</response> /// <response code="404">Video or attachment not found.</response> /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns> - [HttpGet("{VideoID}/{MediaSourceID}/Attachments/{Index}")] + [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")] [Produces(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs new file mode 100644 index 000000000..effe630a9 --- /dev/null +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -0,0 +1,202 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// <summary> + /// The videos controller. + /// </summary> + [Route("Videos")] + public class VideosController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + + /// <summary> + /// Initializes a new instance of the <see cref="VideosController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public VideosController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } + + /// <summary> + /// Gets additional parts for a video. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Additional parts returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the parts.</returns> + [HttpGet("{itemId}/AdditionalParts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute] Guid itemId, [FromQuery] Guid userId) + { + 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); + + var dtoOptions = new DtoOptions(); + dtoOptions = dtoOptions.AddClientFields(Request); + + BaseItemDto[] items; + if (item is Video video) + { + items = video.GetAdditionalParts() + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) + .ToArray(); + } + else + { + items = Array.Empty<BaseItemDto>(); + } + + var result = new QueryResult<BaseItemDto> + { + Items = items, + TotalRecordCount = items.Length + }; + + return result; + } + + /// <summary> + /// Removes alternate video sources. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="204">Alternate sources deleted.</response> + /// <response code="404">Video not found.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="NotFoundResult"/> if the video doesn't exist.</returns> + [HttpDelete("{itemId}/AlternateSources")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteAlternateSources([FromRoute] Guid itemId) + { + var video = (Video)_libraryManager.GetItemById(itemId); + + if (video == null) + { + return NotFound("The video either does not exist or the id does not belong to a video."); + } + + foreach (var link in video.GetLinkedAlternateVersions()) + { + link.SetPrimaryVersionId(null); + link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + + link.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + } + + video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + video.SetPrimaryVersionId(null); + video.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + + return NoContent(); + } + + /// <summary> + /// Merges videos into a single record. + /// </summary> + /// <param name="itemIds">Item id list. This allows multiple, comma delimited.</param> + /// <response code="204">Videos merged.</response> + /// <response code="400">Supply at least 2 video ids.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns> + [HttpPost("MergeVersions")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult MergeVersions([FromQuery] string itemIds) + { + var items = RequestHelpers.Split(itemIds, ',', true) + .Select(i => _libraryManager.GetItemById(i)) + .OfType<Video>() + .OrderBy(i => i.Id) + .ToList(); + + if (items.Count < 2) + { + return BadRequest("Please supply at least two videos to merge."); + } + + var videosWithVersions = items.Where(i => i.MediaSourceCount > 1).ToList(); + + var primaryVersion = videosWithVersions.FirstOrDefault(); + if (primaryVersion == null) + { + primaryVersion = items + .OrderBy(i => + { + if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile) + { + return 1; + } + + return 0; + }) + .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0) + .First(); + } + + var list = primaryVersion.LinkedAlternateVersions.ToList(); + + foreach (var item in items.Where(i => i.Id != primaryVersion.Id)) + { + item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); + + item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + + list.Add(new LinkedChild + { + Path = item.Path, + ItemId = item.Id + }); + + foreach (var linkedItem in item.LinkedAlternateVersions) + { + if (!list.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) + { + list.Add(linkedItem); + } + } + + if (item.LinkedAlternateVersions.Length > 0) + { + item.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + } + } + + primaryVersion.LinkedAlternateVersions = list.ToArray(); + primaryVersion.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs new file mode 100644 index 000000000..ac248109d --- /dev/null +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -0,0 +1,162 @@ +using System; +using System.Linq; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Extensions +{ + /// <summary> + /// Dto Extensions. + /// </summary> + public static class DtoExtensions + { + /// <summary> + /// Add Dto Item fields. + /// </summary> + /// <remarks> + /// Converted from IHasItemFields. + /// Legacy order: 1. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="fields">Comma delimited string of fields.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddItemFields(this DtoOptions dtoOptions, string fields) + { + if (string.IsNullOrEmpty(fields)) + { + dtoOptions.Fields = Array.Empty<ItemFields>(); + } + else + { + dtoOptions.Fields = fields.Split(',') + .Select(v => + { + if (Enum.TryParse(v, true, out ItemFields value)) + { + return (ItemFields?)value; + } + + return null; + }) + .Where(i => i.HasValue) + .Select(i => i!.Value) + .ToArray(); + } + + return dtoOptions; + } + + /// <summary> + /// Add additional fields depending on client. + /// </summary> + /// <remarks> + /// Use in place of GetDtoOptions. + /// Legacy order: 2. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="request">Current request.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddClientFields( + this DtoOptions dtoOptions, HttpRequest request) + { + dtoOptions.Fields ??= Array.Empty<ItemFields>(); + + string? client = ClaimHelpers.GetClient(request.HttpContext.User); + + // No client in claim + if (string.IsNullOrEmpty(client)) + { + return dtoOptions; + } + + if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount)) + { + if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1) + { + int oldLen = dtoOptions.Fields.Length; + var arr = new ItemFields[oldLen + 1]; + dtoOptions.Fields.CopyTo(arr, 0); + arr[oldLen] = ItemFields.RecursiveItemCount; + dtoOptions.Fields = arr; + } + } + + if (!dtoOptions.ContainsField(ItemFields.ChildCount)) + { + if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1) + { + int oldLen = dtoOptions.Fields.Length; + var arr = new ItemFields[oldLen + 1]; + dtoOptions.Fields.CopyTo(arr, 0); + arr[oldLen] = ItemFields.ChildCount; + dtoOptions.Fields = arr; + } + } + + return dtoOptions; + } + + /// <summary> + /// Add additional DtoOptions. + /// </summary> + /// <remarks> + /// Converted from IHasDtoOptions. + /// Legacy order: 3. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="enableImages">Enable images.</param> + /// <param name="enableUserData">Enable user data.</param> + /// <param name="imageTypeLimit">Image type limit.</param> + /// <param name="enableImageTypes">Enable image types.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddAdditionalDtoOptions( + this DtoOptions dtoOptions, + bool? enableImages, + bool? enableUserData, + int? imageTypeLimit, + string enableImageTypes) + { + dtoOptions.EnableImages = enableImages ?? true; + + if (imageTypeLimit.HasValue) + { + dtoOptions.ImageTypeLimit = imageTypeLimit.Value; + } + + if (enableUserData.HasValue) + { + dtoOptions.EnableUserData = enableUserData.Value; + } + + if (!string.IsNullOrWhiteSpace(enableImageTypes)) + { + dtoOptions.ImageTypes = enableImageTypes.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(v => (ImageType)Enum.Parse(typeof(ImageType), v, true)) + .ToArray(); + } + + return dtoOptions; + } + + /// <summary> + /// Check if DtoOptions contains field. + /// </summary> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="field">Field to check.</param> + /// <returns>Field existence.</returns> + internal static bool ContainsField(this DtoOptions dtoOptions, ItemFields field) + => dtoOptions.Fields != null && dtoOptions.Fields.Contains(field); + } +} diff --git a/Jellyfin.Api/Helpers/ClaimHelpers.cs b/Jellyfin.Api/Helpers/ClaimHelpers.cs index a07d4ed82..df235ced2 100644 --- a/Jellyfin.Api/Helpers/ClaimHelpers.cs +++ b/Jellyfin.Api/Helpers/ClaimHelpers.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System; +using System; using System.Linq; using System.Security.Claims; using Jellyfin.Api.Constants; diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 9f4d34f9c..e2a0cf4fa 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -1,4 +1,9 @@ using System; +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using Microsoft.AspNetCore.Http; namespace Jellyfin.Api.Helpers { @@ -25,5 +30,66 @@ namespace Jellyfin.Api.Helpers ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries) : value.Split(separator); } + + /// <summary> + /// Checks if the user can update an entry. + /// </summary> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="requestContext">The <see cref="HttpRequest"/>.</param> + /// <param name="userId">The user id.</param> + /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param> + /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns> + internal static bool AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences) + { + var auth = authContext.GetAuthorizationInfo(requestContext); + + var authenticatedUser = auth.User; + + // If they're going to update the record of another user, they must be an administrator + if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator)) + || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess)) + { + return false; + } + + return true; + } + + internal static SessionInfo GetSession(ISessionManager sessionManager, IAuthorizationContext authContext, HttpRequest request) + { + var authorization = authContext.GetAuthorizationInfo(request); + var user = authorization.User; + var session = sessionManager.LogSessionActivity( + authorization.Client, + authorization.Version, + authorization.DeviceId, + authorization.Device, + request.HttpContext.Connection.RemoteIpAddress.ToString(), + user); + + if (session == null) + { + throw new ArgumentException("Session not found."); + } + + return session; + } + + /// <summary> + /// Get Guid array from string. + /// </summary> + /// <param name="value">String value.</param> + /// <returns>Guid array.</returns> + internal static Guid[] GetGuids(string? value) + { + if (value == null) + { + return Array.Empty<Guid>(); + } + + return value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(i => new Guid(i)) + .ToArray(); + } } } diff --git a/MediaBrowser.WebDashboard/Api/ConfigurationPageInfo.cs b/Jellyfin.Api/Models/ConfigurationPageInfo.cs index e49a4be8a..2aa6373aa 100644 --- a/MediaBrowser.WebDashboard/Api/ConfigurationPageInfo.cs +++ b/Jellyfin.Api/Models/ConfigurationPageInfo.cs @@ -1,13 +1,18 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Common.Plugins; +using MediaBrowser.Common.Plugins; using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Plugins; -namespace MediaBrowser.WebDashboard.Api +namespace Jellyfin.Api.Models { + /// <summary> + /// The configuration page info. + /// </summary> public class ConfigurationPageInfo { + /// <summary> + /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. + /// </summary> + /// <param name="page">Instance of <see cref="IPluginConfigurationPage"/> interface.</param> public ConfigurationPageInfo(IPluginConfigurationPage page) { Name = page.Name; @@ -22,6 +27,11 @@ namespace MediaBrowser.WebDashboard.Api } } + /// <summary> + /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. + /// </summary> + /// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param> + /// <param name="page">Instance of <see cref="PluginPageInfo"/> interface.</param> public ConfigurationPageInfo(IPlugin plugin, PluginPageInfo page) { Name = page.Name; @@ -40,13 +50,25 @@ namespace MediaBrowser.WebDashboard.Api /// <value>The name.</value> public string Name { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the configurations page is enabled in the main menu. + /// </summary> public bool EnableInMainMenu { get; set; } - public string MenuSection { get; set; } + /// <summary> + /// Gets or sets the menu section. + /// </summary> + public string? MenuSection { get; set; } - public string MenuIcon { get; set; } + /// <summary> + /// Gets or sets the menu icon. + /// </summary> + public string? MenuIcon { get; set; } - public string DisplayName { get; set; } + /// <summary> + /// Gets or sets the display name. + /// </summary> + public string? DisplayName { get; set; } /// <summary> /// Gets or sets the type of the configuration page. @@ -58,6 +80,6 @@ namespace MediaBrowser.WebDashboard.Api /// Gets or sets the plugin id. /// </summary> /// <value>The plugin id.</value> - public string PluginId { get; set; } + public string? PluginId { get; set; } } } diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs new file mode 100644 index 000000000..0d67c86f7 --- /dev/null +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -0,0 +1,30 @@ +using System; + +namespace Jellyfin.Api.Models.PlaylistDtos +{ + /// <summary> + /// Create new playlist dto. + /// </summary> + public class CreatePlaylistDto + { + /// <summary> + /// Gets or sets the name of the new playlist. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets item ids to add to the playlist. + /// </summary> + public string? Ids { get; set; } + + /// <summary> + /// Gets or sets the user id. + /// </summary> + public Guid UserId { get; set; } + + /// <summary> + /// Gets or sets the media type. + /// </summary> + public string? MediaType { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs new file mode 100644 index 000000000..393627435 --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The authenticate user by name request body. + /// </summary> + public class AuthenticateUserByName + { + /// <summary> + /// Gets or sets the username. + /// </summary> + public string? Username { get; set; } + + /// <summary> + /// Gets or sets the plain text password. + /// </summary> + public string? Pw { get; set; } + + /// <summary> + /// Gets or sets the sha1-hashed password. + /// </summary> + public string? Password { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs b/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs new file mode 100644 index 000000000..1c88d3628 --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The create user by name request body. + /// </summary> + public class CreateUserByName + { + /// <summary> + /// Gets or sets the username. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets the password. + /// </summary> + public string? Password { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs new file mode 100644 index 000000000..0a173ea1a --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs @@ -0,0 +1,23 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The update user easy password request body. + /// </summary> + public class UpdateUserEasyPassword + { + /// <summary> + /// Gets or sets the new sha1-hashed password. + /// </summary> + public string? NewPassword { get; set; } + + /// <summary> + /// Gets or sets the new password. + /// </summary> + public string? NewPw { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to reset the password. + /// </summary> + public bool ResetPassword { get; set; } + } +} diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs new file mode 100644 index 000000000..8288dbbc4 --- /dev/null +++ b/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs @@ -0,0 +1,28 @@ +namespace Jellyfin.Api.Models.UserDtos +{ + /// <summary> + /// The update user password request body. + /// </summary> + public class UpdateUserPassword + { + /// <summary> + /// Gets or sets the current sha1-hashed password. + /// </summary> + public string? CurrentPassword { get; set; } + + /// <summary> + /// Gets or sets the current plain text password. + /// </summary> + public string? CurrentPw { get; set; } + + /// <summary> + /// Gets or sets the new plain text password. + /// </summary> + public string? NewPw { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to reset the password. + /// </summary> + public bool ResetPassword { get; set; } + } +} diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index dbd5ba416..aad61d042 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -215,6 +215,31 @@ namespace Jellyfin.Server.Extensions Format = "string" }) }); + + /* + * Support BlurHash dictionary + */ + options.MapType<Dictionary<ImageType, Dictionary<string, string>>>(() => + new OpenApiSchema + { + Type = "object", + Properties = typeof(ImageType).GetEnumNames().ToDictionary( + name => name, + name => new OpenApiSchema + { + Type = "object", Properties = new Dictionary<string, OpenApiSchema> + { + { + "string", + new OpenApiSchema + { + Type = "string", + Format = "string" + } + } + } + }) + }); } } } diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 3971a08e9..dfc7bbbb1 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -14,9 +14,9 @@ using Emby.Server.Implementations; using Emby.Server.Implementations.HttpServer; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Networking; +using Jellyfin.Api.Controllers; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Extensions; -using MediaBrowser.WebDashboard.Api; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; @@ -172,7 +172,7 @@ namespace Jellyfin.Server // If hosting the web client, validate the client content path if (startupConfig.HostWebClient()) { - string webContentPath = DashboardService.GetDashboardUIPath(startupConfig, appHost.ServerConfigurationManager); + string? webContentPath = DashboardController.GetWebClientUiPath(startupConfig, appHost.ServerConfigurationManager); if (!Directory.Exists(webContentPath) || Directory.GetFiles(webContentPath).Length == 0) { throw new InvalidOperationException( diff --git a/MediaBrowser.Api/BrandingService.cs b/MediaBrowser.Api/BrandingService.cs deleted file mode 100644 index f4724e774..000000000 --- a/MediaBrowser.Api/BrandingService.cs +++ /dev/null @@ -1,44 +0,0 @@ -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Branding; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Branding/Configuration", "GET", Summary = "Gets branding configuration")] - public class GetBrandingOptions : IReturn<BrandingOptions> - { - } - - [Route("/Branding/Css", "GET", Summary = "Gets custom css")] - [Route("/Branding/Css.css", "GET", Summary = "Gets custom css")] - public class GetBrandingCss - { - } - - public class BrandingService : BaseApiService - { - public BrandingService( - ILogger<BrandingService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory) - : base(logger, serverConfigurationManager, httpResultFactory) - { - } - - public object Get(GetBrandingOptions request) - { - return ServerConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - } - - public object Get(GetBrandingCss request) - { - var result = ServerConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - - // When null this throws a 405 error under Mono OSX, so default to empty string - return ResultFactory.GetResult(Request, result.CustomCss ?? string.Empty, "text/css"); - } - } -} diff --git a/MediaBrowser.Api/DisplayPreferencesService.cs b/MediaBrowser.Api/DisplayPreferencesService.cs deleted file mode 100644 index 62c4ff43f..000000000 --- a/MediaBrowser.Api/DisplayPreferencesService.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Threading; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class UpdateDisplayPreferences - /// </summary> - [Route("/DisplayPreferences/{DisplayPreferencesId}", "POST", Summary = "Updates a user's display preferences for an item")] - public class UpdateDisplayPreferences : DisplayPreferences, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "DisplayPreferencesId", Description = "DisplayPreferences Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string DisplayPreferencesId { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string UserId { get; set; } - } - - [Route("/DisplayPreferences/{Id}", "GET", Summary = "Gets a user's display preferences for an item")] - public class GetDisplayPreferences : IReturn<DisplayPreferences> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string UserId { get; set; } - - [ApiMember(Name = "Client", Description = "Client", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Client { get; set; } - } - - /// <summary> - /// Class DisplayPreferencesService - /// </summary> - [Authenticated] - public class DisplayPreferencesService : BaseApiService - { - /// <summary> - /// The _display preferences manager - /// </summary> - private readonly IDisplayPreferencesRepository _displayPreferencesManager; - /// <summary> - /// The _json serializer - /// </summary> - private readonly IJsonSerializer _jsonSerializer; - - /// <summary> - /// Initializes a new instance of the <see cref="DisplayPreferencesService" /> class. - /// </summary> - /// <param name="jsonSerializer">The json serializer.</param> - /// <param name="displayPreferencesManager">The display preferences manager.</param> - public DisplayPreferencesService( - ILogger<DisplayPreferencesService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IJsonSerializer jsonSerializer, - IDisplayPreferencesRepository displayPreferencesManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _jsonSerializer = jsonSerializer; - _displayPreferencesManager = displayPreferencesManager; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Get(GetDisplayPreferences request) - { - var result = _displayPreferencesManager.GetDisplayPreferences(request.Id, request.UserId, request.Client); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public void Post(UpdateDisplayPreferences request) - { - // Serialize to json and then back so that the core doesn't see the request dto type - var displayPreferences = _jsonSerializer.DeserializeFromString<DisplayPreferences>(_jsonSerializer.SerializeToString(request)); - - _displayPreferencesManager.SaveDisplayPreferences(displayPreferences, request.UserId, request.Client, CancellationToken.None); - } - } -} diff --git a/MediaBrowser.Api/ItemLookupService.cs b/MediaBrowser.Api/ItemLookupService.cs deleted file mode 100644 index 68e3dfa59..000000000 --- a/MediaBrowser.Api/ItemLookupService.cs +++ /dev/null @@ -1,336 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Providers; -using MediaBrowser.Model.Serialization; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Items/{Id}/ExternalIdInfos", "GET", Summary = "Gets external id infos for an item")] - [Authenticated(Roles = "Admin")] - public class GetExternalIdInfos : IReturn<List<ExternalIdInfo>> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - } - - [Route("/Items/RemoteSearch/Movie", "POST")] - [Authenticated] - public class GetMovieRemoteSearchResults : RemoteSearchQuery<MovieInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/Trailer", "POST")] - [Authenticated] - public class GetTrailerRemoteSearchResults : RemoteSearchQuery<TrailerInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/MusicVideo", "POST")] - [Authenticated] - public class GetMusicVideoRemoteSearchResults : RemoteSearchQuery<MusicVideoInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/Series", "POST")] - [Authenticated] - public class GetSeriesRemoteSearchResults : RemoteSearchQuery<SeriesInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/BoxSet", "POST")] - [Authenticated] - public class GetBoxSetRemoteSearchResults : RemoteSearchQuery<BoxSetInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/MusicArtist", "POST")] - [Authenticated] - public class GetMusicArtistRemoteSearchResults : RemoteSearchQuery<ArtistInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/MusicAlbum", "POST")] - [Authenticated] - public class GetMusicAlbumRemoteSearchResults : RemoteSearchQuery<AlbumInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/Person", "POST")] - [Authenticated(Roles = "Admin")] - public class GetPersonRemoteSearchResults : RemoteSearchQuery<PersonLookupInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/Book", "POST")] - [Authenticated] - public class GetBookRemoteSearchResults : RemoteSearchQuery<BookInfo>, IReturn<List<RemoteSearchResult>> - { - } - - [Route("/Items/RemoteSearch/Image", "GET", Summary = "Gets a remote image")] - public class GetRemoteSearchImage - { - [ApiMember(Name = "ImageUrl", Description = "The image url", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ImageUrl { get; set; } - - [ApiMember(Name = "ProviderName", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string ProviderName { get; set; } - } - - [Route("/Items/RemoteSearch/Apply/{Id}", "POST", Summary = "Applies search criteria to an item and refreshes metadata")] - [Authenticated(Roles = "Admin")] - public class ApplySearchCriteria : RemoteSearchResult, IReturnVoid - { - [ApiMember(Name = "Id", Description = "The item id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "ReplaceAllImages", Description = "Whether or not to replace all images", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")] - public bool ReplaceAllImages { get; set; } - - public ApplySearchCriteria() - { - ReplaceAllImages = true; - } - } - - public class ItemLookupService : BaseApiService - { - private readonly IProviderManager _providerManager; - private readonly IServerApplicationPaths _appPaths; - private readonly IFileSystem _fileSystem; - private readonly ILibraryManager _libraryManager; - private readonly IJsonSerializer _json; - - public ItemLookupService( - ILogger<ItemLookupService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IProviderManager providerManager, - IFileSystem fileSystem, - ILibraryManager libraryManager, - IJsonSerializer json) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _providerManager = providerManager; - _appPaths = serverConfigurationManager.ApplicationPaths; - _fileSystem = fileSystem; - _libraryManager = libraryManager; - _json = json; - } - - public object Get(GetExternalIdInfos request) - { - var item = _libraryManager.GetItemById(request.Id); - - var infos = _providerManager.GetExternalIdInfos(item).ToList(); - - return ToOptimizedResult(infos); - } - - public async Task<object> Post(GetTrailerRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetBookRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetMovieRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetSeriesRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetBoxSetRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetMusicVideoRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetPersonRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetMusicAlbumRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(GetMusicArtistRemoteSearchResults request) - { - var result = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(request, CancellationToken.None).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public Task<object> Get(GetRemoteSearchImage request) - { - return GetRemoteImage(request); - } - - public Task Post(ApplySearchCriteria request) - { - var item = _libraryManager.GetItemById(new Guid(request.Id)); - - //foreach (var key in request.ProviderIds) - //{ - // var value = key.Value; - - // if (!string.IsNullOrWhiteSpace(value)) - // { - // item.SetProviderId(key.Key, value); - // } - //} - Logger.LogInformation("Setting provider id's to item {0}-{1}: {2}", item.Id, item.Name, _json.SerializeToString(request.ProviderIds)); - - // Since the refresh process won't erase provider Ids, we need to set this explicitly now. - item.ProviderIds = request.ProviderIds; - //item.ProductionYear = request.ProductionYear; - //item.Name = request.Name; - - return _providerManager.RefreshFullItem( - item, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true, - ReplaceAllImages = request.ReplaceAllImages, - SearchResult = request - }, - CancellationToken.None); - } - - /// <summary> - /// Gets the remote image. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>Task{System.Object}.</returns> - private async Task<object> GetRemoteImage(GetRemoteSearchImage request) - { - var urlHash = request.ImageUrl.GetMD5(); - var pointerCachePath = GetFullCachePath(urlHash.ToString()); - - string contentPath; - - try - { - contentPath = File.ReadAllText(pointerCachePath); - - if (File.Exists(contentPath)) - { - return await ResultFactory.GetStaticFileResult(Request, contentPath).ConfigureAwait(false); - } - } - catch (FileNotFoundException) - { - // Means the file isn't cached yet - } - catch (IOException) - { - // Means the file isn't cached yet - } - - await DownloadImage(request.ProviderName, request.ImageUrl, urlHash, pointerCachePath).ConfigureAwait(false); - - // Read the pointer file again - contentPath = File.ReadAllText(pointerCachePath); - - return await ResultFactory.GetStaticFileResult(Request, contentPath).ConfigureAwait(false); - } - - /// <summary> - /// Downloads the image. - /// </summary> - /// <param name="providerName">Name of the provider.</param> - /// <param name="url">The URL.</param> - /// <param name="urlHash">The URL hash.</param> - /// <param name="pointerCachePath">The pointer cache path.</param> - /// <returns>Task.</returns> - private async Task DownloadImage(string providerName, string url, Guid urlHash, string pointerCachePath) - { - var result = await _providerManager.GetSearchImage(providerName, url, CancellationToken.None).ConfigureAwait(false); - - var ext = result.ContentType.Split('/')[^1]; - - var fullCachePath = GetFullCachePath(urlHash + "." + ext); - - Directory.CreateDirectory(Path.GetDirectoryName(fullCachePath)); - var stream = result.Content; - - await using (stream.ConfigureAwait(false)) - { - var fileStream = new FileStream( - fullCachePath, - FileMode.Create, - FileAccess.Write, - FileShare.Read, - IODefaults.FileStreamBufferSize, - true); - await using (fileStream.ConfigureAwait(false)) - { - await stream.CopyToAsync(fileStream).ConfigureAwait(false); - } - } - - Directory.CreateDirectory(Path.GetDirectoryName(pointerCachePath)); - File.WriteAllText(pointerCachePath, fullCachePath); - } - - /// <summary> - /// Gets the full cache path. - /// </summary> - /// <param name="filename">The filename.</param> - /// <returns>System.String.</returns> - private string GetFullCachePath(string filename) - => Path.Combine(_appPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); - } -} diff --git a/MediaBrowser.Api/PlaylistService.cs b/MediaBrowser.Api/PlaylistService.cs deleted file mode 100644 index 953b00e35..000000000 --- a/MediaBrowser.Api/PlaylistService.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Playlists; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Playlists; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Playlists", "POST", Summary = "Creates a new playlist")] - public class CreatePlaylist : IReturn<PlaylistCreationResult> - { - [ApiMember(Name = "Name", Description = "The name of the new playlist.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Name { get; set; } - - [ApiMember(Name = "Ids", Description = "Item Ids to add to the playlist", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)] - public string Ids { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid UserId { get; set; } - - [ApiMember(Name = "MediaType", Description = "The playlist media type", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string MediaType { get; set; } - } - - [Route("/Playlists/{Id}/Items", "POST", Summary = "Adds items to a playlist")] - public class AddToPlaylist : IReturnVoid - { - [ApiMember(Name = "Ids", Description = "Item id, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Ids { get; set; } - - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public Guid UserId { get; set; } - } - - [Route("/Playlists/{Id}/Items/{ItemId}/Move/{NewIndex}", "POST", Summary = "Moves a playlist item")] - public class MoveItem : IReturnVoid - { - [ApiMember(Name = "ItemId", Description = "ItemId", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ItemId { get; set; } - - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "NewIndex", Description = "NewIndex", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public int NewIndex { get; set; } - } - - [Route("/Playlists/{Id}/Items", "DELETE", Summary = "Removes items from a playlist")] - public class RemoveFromPlaylist : IReturnVoid - { - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - - [ApiMember(Name = "EntryIds", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string EntryIds { get; set; } - } - - [Route("/Playlists/{Id}/Items", "GET", Summary = "Gets the original items of a playlist")] - public class GetPlaylistItems : IReturn<QueryResult<BaseItemDto>>, IHasDtoOptions - { - [ApiMember(Name = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid Id { get; set; } - - /// <summary> - /// Gets or sets the user id. - /// </summary> - /// <value>The user id.</value> - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// <summary> - /// Skips over a given number of items within the results. Use for paging. - /// </summary> - /// <value>The start index.</value> - [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; } - - /// <summary> - /// The maximum number of items to return - /// </summary> - /// <value>The limit.</value> - [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; } - - /// <summary> - /// Fields to return within the items, in addition to basic information - /// </summary> - /// <value>The fields.</value> - [ApiMember(Name = "Fields", Description = "Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string Fields { get; set; } - - [ApiMember(Name = "EnableImages", Description = "Optional, include image information in output", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableImages { get; set; } - - [ApiMember(Name = "EnableUserData", Description = "Optional, include user data", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "GET")] - public bool? EnableUserData { get; set; } - - [ApiMember(Name = "ImageTypeLimit", Description = "Optional, the max number of images to return, per image type", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? ImageTypeLimit { get; set; } - - [ApiMember(Name = "EnableImageTypes", Description = "Optional. The image types to include in the output.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string EnableImageTypes { get; set; } - } - - [Authenticated] - public class PlaylistService : BaseApiService - { - private readonly IPlaylistManager _playlistManager; - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IAuthorizationContext _authContext; - - public PlaylistService( - ILogger<PlaylistService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IDtoService dtoService, - IPlaylistManager playlistManager, - IUserManager userManager, - ILibraryManager libraryManager, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _dtoService = dtoService; - _playlistManager = playlistManager; - _userManager = userManager; - _libraryManager = libraryManager; - _authContext = authContext; - } - - public void Post(MoveItem request) - { - _playlistManager.MoveItem(request.Id, request.ItemId, request.NewIndex); - } - - public async Task<object> Post(CreatePlaylist request) - { - var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest - { - Name = request.Name, - ItemIdList = GetGuids(request.Ids), - UserId = request.UserId, - MediaType = request.MediaType - - }).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - - public void Post(AddToPlaylist request) - { - _playlistManager.AddToPlaylist(request.Id, GetGuids(request.Ids), request.UserId); - } - - public void Delete(RemoveFromPlaylist request) - { - _playlistManager.RemoveFromPlaylist(request.Id, request.EntryIds.Split(',')); - } - - public object Get(GetPlaylistItems request) - { - var playlist = (Playlist)_libraryManager.GetItemById(request.Id); - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var items = playlist.GetManageableItems().ToArray(); - - var count = items.Length; - - if (request.StartIndex.HasValue) - { - items = items.Skip(request.StartIndex.Value).ToArray(); - } - - if (request.Limit.HasValue) - { - items = items.Take(request.Limit.Value).ToArray(); - } - - var dtoOptions = GetDtoOptions(_authContext, request); - - var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); - - for (int index = 0; index < dtos.Count; index++) - { - dtos[index].PlaylistItemId = items[index].Item1.Id; - } - - var result = new QueryResult<BaseItemDto> - { - Items = dtos, - TotalRecordCount = count - }; - - return ToOptimizedResult(result); - } - } -} diff --git a/MediaBrowser.Api/Sessions/SessionService.cs b/MediaBrowser.Api/Sessions/SessionService.cs deleted file mode 100644 index d986eea65..000000000 --- a/MediaBrowser.Api/Sessions/SessionService.cs +++ /dev/null @@ -1,499 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.Session; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Sessions -{ - /// <summary> - /// Class GetSessions. - /// </summary> - [Route("/Sessions", "GET", Summary = "Gets a list of sessions")] - [Authenticated] - public class GetSessions : IReturn<SessionInfo[]> - { - [ApiMember(Name = "ControllableByUserId", Description = "Filter by sessions that a given user is allowed to remote control.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid ControllableByUserId { get; set; } - - [ApiMember(Name = "DeviceId", Description = "Filter by device Id.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string DeviceId { get; set; } - - public int? ActiveWithinSeconds { get; set; } - } - - /// <summary> - /// Class DisplayContent. - /// </summary> - [Route("/Sessions/{Id}/Viewing", "POST", Summary = "Instructs a session to browse to an item or view")] - [Authenticated] - public class DisplayContent : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Artist, Genre, Studio, Person, or any kind of BaseItem - /// </summary> - /// <value>The type of the item.</value> - [ApiMember(Name = "ItemType", Description = "The type of item to browse to.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ItemType { get; set; } - - /// <summary> - /// Artist name, genre name, item Id, etc - /// </summary> - /// <value>The item identifier.</value> - [ApiMember(Name = "ItemId", Description = "The Id of the item.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ItemId { get; set; } - - /// <summary> - /// Gets or sets the name of the item. - /// </summary> - /// <value>The name of the item.</value> - [ApiMember(Name = "ItemName", Description = "The name of the item.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ItemName { get; set; } - } - - [Route("/Sessions/{Id}/Playing", "POST", Summary = "Instructs a session to play an item")] - [Authenticated] - public class Play : PlayRequest - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - [Route("/Sessions/{Id}/Playing/{Command}", "POST", Summary = "Issues a playstate command to a client")] - [Authenticated] - public class SendPlaystateCommand : PlaystateRequest, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - [Route("/Sessions/{Id}/System/{Command}", "POST", Summary = "Issues a system command to a client")] - [Authenticated] - public class SendSystemCommand : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Gets or sets the command. - /// </summary> - /// <value>The play command.</value> - [ApiMember(Name = "Command", Description = "The command to send.", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Command { get; set; } - } - - [Route("/Sessions/{Id}/Command/{Command}", "POST", Summary = "Issues a system command to a client")] - [Authenticated] - public class SendGeneralCommand : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - /// <summary> - /// Gets or sets the command. - /// </summary> - /// <value>The play command.</value> - [ApiMember(Name = "Command", Description = "The command to send.", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Command { get; set; } - } - - [Route("/Sessions/{Id}/Command", "POST", Summary = "Issues a system command to a client")] - [Authenticated] - public class SendFullGeneralCommand : GeneralCommand, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - } - - [Route("/Sessions/{Id}/Message", "POST", Summary = "Issues a command to a client to display a message to the user")] - [Authenticated] - public class SendMessageCommand : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "Text", Description = "The message text.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Text { get; set; } - - [ApiMember(Name = "Header", Description = "The message header.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Header { get; set; } - - [ApiMember(Name = "TimeoutMs", Description = "The message timeout. If omitted the user will have to confirm viewing the message.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public long? TimeoutMs { get; set; } - } - - [Route("/Sessions/{Id}/Users/{UserId}", "POST", Summary = "Adds an additional user to a session")] - [Authenticated] - public class AddUserToSession : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "UserId", Description = "UserId Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string UserId { get; set; } - } - - [Route("/Sessions/{Id}/Users/{UserId}", "DELETE", Summary = "Removes an additional user from a session")] - [Authenticated] - public class RemoveUserFromSession : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string UserId { get; set; } - } - - [Route("/Sessions/Capabilities", "POST", Summary = "Updates capabilities for a device")] - [Authenticated] - public class PostCapabilities : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Id { get; set; } - - [ApiMember(Name = "PlayableMediaTypes", Description = "A list of playable media types, comma delimited. Audio, Video, Book, Photo.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string PlayableMediaTypes { get; set; } - - [ApiMember(Name = "SupportedCommands", Description = "A list of supported remote control commands, comma delimited", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string SupportedCommands { get; set; } - - [ApiMember(Name = "SupportsMediaControl", Description = "Determines whether media can be played remotely.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "POST")] - public bool SupportsMediaControl { get; set; } - - [ApiMember(Name = "SupportsSync", Description = "Determines whether sync is supported.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "POST")] - public bool SupportsSync { get; set; } - - [ApiMember(Name = "SupportsPersistentIdentifier", Description = "Determines whether the device supports a unique identifier.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "POST")] - public bool SupportsPersistentIdentifier { get; set; } - - public PostCapabilities() - { - SupportsPersistentIdentifier = true; - } - } - - [Route("/Sessions/Capabilities/Full", "POST", Summary = "Updates capabilities for a device")] - [Authenticated] - public class PostFullCapabilities : ClientCapabilities, IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Session Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Id { get; set; } - } - - [Route("/Sessions/Viewing", "POST", Summary = "Reports that a session is viewing an item")] - [Authenticated] - public class ReportViewing : IReturnVoid - { - [ApiMember(Name = "SessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string SessionId { get; set; } - - [ApiMember(Name = "ItemId", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string ItemId { get; set; } - } - - [Route("/Sessions/Logout", "POST", Summary = "Reports that a session has ended")] - [Authenticated] - public class ReportSessionEnded : IReturnVoid - { - } - - [Route("/Auth/Providers", "GET")] - [Authenticated(Roles = "Admin")] - public class GetAuthProviders : IReturn<NameIdPair[]> - { - } - - [Route("/Auth/PasswordResetProviders", "GET")] - [Authenticated(Roles = "Admin")] - public class GetPasswordResetProviders : IReturn<NameIdPair[]> - { - } - - /// <summary> - /// Class SessionsService. - /// </summary> - public class SessionService : BaseApiService - { - /// <summary> - /// The session manager. - /// </summary> - private readonly ISessionManager _sessionManager; - - private readonly IUserManager _userManager; - private readonly IAuthorizationContext _authContext; - private readonly IDeviceManager _deviceManager; - private readonly ISessionContext _sessionContext; - - public SessionService( - ILogger<SessionService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ISessionManager sessionManager, - IUserManager userManager, - IAuthorizationContext authContext, - IDeviceManager deviceManager, - ISessionContext sessionContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _sessionManager = sessionManager; - _userManager = userManager; - _authContext = authContext; - _deviceManager = deviceManager; - _sessionContext = sessionContext; - } - - public object Get(GetAuthProviders request) - { - return _userManager.GetAuthenticationProviders(); - } - - public object Get(GetPasswordResetProviders request) - { - return _userManager.GetPasswordResetProviders(); - } - - public void Post(ReportSessionEnded request) - { - var auth = _authContext.GetAuthorizationInfo(Request); - - _sessionManager.Logout(auth.Token); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetSessions request) - { - var result = _sessionManager.Sessions; - - if (!string.IsNullOrEmpty(request.DeviceId)) - { - result = result.Where(i => string.Equals(i.DeviceId, request.DeviceId, StringComparison.OrdinalIgnoreCase)); - } - - if (!request.ControllableByUserId.Equals(Guid.Empty)) - { - result = result.Where(i => i.SupportsRemoteControl); - - var user = _userManager.GetUserById(request.ControllableByUserId); - - if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) - { - result = result.Where(i => i.UserId.Equals(Guid.Empty) || i.ContainsUser(request.ControllableByUserId)); - } - - if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) - { - result = result.Where(i => !i.UserId.Equals(Guid.Empty)); - } - - if (request.ActiveWithinSeconds.HasValue && request.ActiveWithinSeconds.Value > 0) - { - var minActiveDate = DateTime.UtcNow.AddSeconds(0 - request.ActiveWithinSeconds.Value); - result = result.Where(i => i.LastActivityDate >= minActiveDate); - } - - result = result.Where(i => - { - var deviceId = i.DeviceId; - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - if (!_deviceManager.CanAccessDevice(user, deviceId)) - { - return false; - } - } - - return true; - }); - } - - return ToOptimizedResult(result.ToArray()); - } - - public Task Post(SendPlaystateCommand request) - { - return _sessionManager.SendPlaystateCommand(GetSession(_sessionContext).Id, request.Id, request, CancellationToken.None); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(DisplayContent request) - { - var command = new BrowseRequest - { - ItemId = request.ItemId, - ItemName = request.ItemName, - ItemType = request.ItemType - }; - - return _sessionManager.SendBrowseCommand(GetSession(_sessionContext).Id, request.Id, command, CancellationToken.None); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(SendSystemCommand request) - { - var name = request.Command; - if (Enum.TryParse(name, true, out GeneralCommandType commandType)) - { - name = commandType.ToString(); - } - - var currentSession = GetSession(_sessionContext); - var command = new GeneralCommand - { - Name = name, - ControllingUserId = currentSession.UserId - }; - - return _sessionManager.SendGeneralCommand(currentSession.Id, request.Id, command, CancellationToken.None); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(SendMessageCommand request) - { - var command = new MessageCommand - { - Header = string.IsNullOrEmpty(request.Header) ? "Message from Server" : request.Header, - TimeoutMs = request.TimeoutMs, - Text = request.Text - }; - - return _sessionManager.SendMessageCommand(GetSession(_sessionContext).Id, request.Id, command, CancellationToken.None); - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(Play request) - { - return _sessionManager.SendPlayCommand(GetSession(_sessionContext).Id, request.Id, request, CancellationToken.None); - } - - public Task Post(SendGeneralCommand request) - { - var currentSession = GetSession(_sessionContext); - - var command = new GeneralCommand - { - Name = request.Command, - ControllingUserId = currentSession.UserId - }; - - return _sessionManager.SendGeneralCommand(currentSession.Id, request.Id, command, CancellationToken.None); - } - - public Task Post(SendFullGeneralCommand request) - { - var currentSession = GetSession(_sessionContext); - - request.ControllingUserId = currentSession.UserId; - - return _sessionManager.SendGeneralCommand(currentSession.Id, request.Id, request, CancellationToken.None); - } - - public void Post(AddUserToSession request) - { - _sessionManager.AddAdditionalUser(request.Id, new Guid(request.UserId)); - } - - public void Delete(RemoveUserFromSession request) - { - _sessionManager.RemoveAdditionalUser(request.Id, new Guid(request.UserId)); - } - - public void Post(PostCapabilities request) - { - if (string.IsNullOrWhiteSpace(request.Id)) - { - request.Id = GetSession(_sessionContext).Id; - } - - _sessionManager.ReportCapabilities(request.Id, new ClientCapabilities - { - PlayableMediaTypes = SplitValue(request.PlayableMediaTypes, ','), - SupportedCommands = SplitValue(request.SupportedCommands, ','), - SupportsMediaControl = request.SupportsMediaControl, - SupportsSync = request.SupportsSync, - SupportsPersistentIdentifier = request.SupportsPersistentIdentifier - }); - } - - public void Post(PostFullCapabilities request) - { - if (string.IsNullOrWhiteSpace(request.Id)) - { - request.Id = GetSession(_sessionContext).Id; - } - - _sessionManager.ReportCapabilities(request.Id, request); - } - - public void Post(ReportViewing request) - { - request.SessionId = GetSession(_sessionContext).Id; - - _sessionManager.ReportNowViewingItem(request.SessionId, request.ItemId); - } - } -} diff --git a/MediaBrowser.Api/SuggestionsService.cs b/MediaBrowser.Api/SuggestionsService.cs deleted file mode 100644 index 32d3bde5c..000000000 --- a/MediaBrowser.Api/SuggestionsService.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Linq; -using Jellyfin.Data.Entities; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Users/{UserId}/Suggestions", "GET", Summary = "Gets items based on a query.")] - public class GetSuggestedItems : IReturn<QueryResult<BaseItemDto>> - { - public string MediaType { get; set; } - public string Type { get; set; } - public Guid UserId { get; set; } - public bool EnableTotalRecordCount { get; set; } - public int? StartIndex { get; set; } - public int? Limit { get; set; } - - public string[] GetMediaTypes() - { - return (MediaType ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - - public string[] GetIncludeItemTypes() - { - return (Type ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - } - } - - public class SuggestionsService : BaseApiService - { - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - - public SuggestionsService( - ILogger<SuggestionsService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IDtoService dtoService, - IAuthorizationContext authContext, - IUserManager userManager, - ILibraryManager libraryManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _dtoService = dtoService; - _authContext = authContext; - _userManager = userManager; - _libraryManager = libraryManager; - } - - public object Get(GetSuggestedItems request) - { - return GetResultItems(request); - } - - private QueryResult<BaseItemDto> GetResultItems(GetSuggestedItems request) - { - var user = !request.UserId.Equals(Guid.Empty) ? _userManager.GetUserById(request.UserId) : null; - - var dtoOptions = GetDtoOptions(_authContext, request); - var result = GetItems(request, user, dtoOptions); - - var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); - - return new QueryResult<BaseItemDto> - { - TotalRecordCount = result.TotalRecordCount, - Items = dtoList - }; - } - - private QueryResult<BaseItem> GetItems(GetSuggestedItems request, User user, DtoOptions dtoOptions) - { - return _libraryManager.GetItemsResult(new InternalItemsQuery(user) - { - OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), - MediaTypes = request.GetMediaTypes(), - IncludeItemTypes = request.GetIncludeItemTypes(), - IsVirtualItem = false, - StartIndex = request.StartIndex, - Limit = request.Limit, - DtoOptions = dtoOptions, - EnableTotalRecordCount = request.EnableTotalRecordCount, - Recursive = true - }); - } - } -} diff --git a/MediaBrowser.Api/TestService.cs b/MediaBrowser.Api/TestService.cs new file mode 100644 index 000000000..6c999e08d --- /dev/null +++ b/MediaBrowser.Api/TestService.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Net; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Api +{ + /// <summary> + /// Service for testing path value. + /// </summary> + public class TestService : BaseApiService + { + /// <summary> + /// Test service. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{TestService}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="httpResultFactory">Instance of the <see cref="IHttpResultFactory"/> interface.</param> + public TestService( + ILogger<TestService> logger, + IServerConfigurationManager serverConfigurationManager, + IHttpResultFactory httpResultFactory) + : base(logger, serverConfigurationManager, httpResultFactory) + { + } + } +} diff --git a/MediaBrowser.Api/UserService.cs b/MediaBrowser.Api/UserService.cs deleted file mode 100644 index 9cb9baf63..000000000 --- a/MediaBrowser.Api/UserService.cs +++ /dev/null @@ -1,605 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.Users; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// <summary> - /// Class GetUsers - /// </summary> - [Route("/Users", "GET", Summary = "Gets a list of users")] - [Authenticated] - public class GetUsers : IReturn<UserDto[]> - { - [ApiMember(Name = "IsHidden", Description = "Optional filter by IsHidden=true or false", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsHidden { get; set; } - - [ApiMember(Name = "IsDisabled", Description = "Optional filter by IsDisabled=true or false", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsDisabled { get; set; } - - [ApiMember(Name = "IsGuest", Description = "Optional filter by IsGuest=true or false", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool? IsGuest { get; set; } - } - - [Route("/Users/Public", "GET", Summary = "Gets a list of publicly visible users for display on a login screen.")] - public class GetPublicUsers : IReturn<UserDto[]> - { - } - - /// <summary> - /// Class GetUser - /// </summary> - [Route("/Users/{Id}", "GET", Summary = "Gets a user by Id")] - [Authenticated(EscapeParentalControl = true)] - public class GetUser : IReturn<UserDto> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class DeleteUser - /// </summary> - [Route("/Users/{Id}", "DELETE", Summary = "Deletes a user")] - [Authenticated(Roles = "Admin")] - public class DeleteUser : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class AuthenticateUser - /// </summary> - [Route("/Users/{Id}/Authenticate", "POST", Summary = "Authenticates a user")] - public class AuthenticateUser : IReturn<AuthenticationResult> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - - [ApiMember(Name = "Pw", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Pw { get; set; } - - /// <summary> - /// Gets or sets the password. - /// </summary> - /// <value>The password.</value> - [ApiMember(Name = "Password", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Password { get; set; } - } - - /// <summary> - /// Class AuthenticateUser - /// </summary> - [Route("/Users/AuthenticateByName", "POST", Summary = "Authenticates a user")] - public class AuthenticateUserByName : IReturn<AuthenticationResult> - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Username", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Username { get; set; } - - /// <summary> - /// Gets or sets the password. - /// </summary> - /// <value>The password.</value> - [ApiMember(Name = "Password", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Password { get; set; } - - [ApiMember(Name = "Pw", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Pw { get; set; } - } - - /// <summary> - /// Class UpdateUserPassword - /// </summary> - [Route("/Users/{Id}/Password", "POST", Summary = "Updates a user's password")] - [Authenticated] - public class UpdateUserPassword : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - public Guid Id { get; set; } - - /// <summary> - /// Gets or sets the password. - /// </summary> - /// <value>The password.</value> - public string CurrentPassword { get; set; } - - public string CurrentPw { get; set; } - - public string NewPw { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [reset password]. - /// </summary> - /// <value><c>true</c> if [reset password]; otherwise, <c>false</c>.</value> - public bool ResetPassword { get; set; } - } - - /// <summary> - /// Class UpdateUserEasyPassword - /// </summary> - [Route("/Users/{Id}/EasyPassword", "POST", Summary = "Updates a user's easy password")] - [Authenticated] - public class UpdateUserEasyPassword : IReturnVoid - { - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - public Guid Id { get; set; } - - /// <summary> - /// Gets or sets the new password. - /// </summary> - /// <value>The new password.</value> - public string NewPassword { get; set; } - - public string NewPw { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether [reset password]. - /// </summary> - /// <value><c>true</c> if [reset password]; otherwise, <c>false</c>.</value> - public bool ResetPassword { get; set; } - } - - /// <summary> - /// Class UpdateUser - /// </summary> - [Route("/Users/{Id}", "POST", Summary = "Updates a user")] - [Authenticated] - public class UpdateUser : UserDto, IReturnVoid - { - } - - /// <summary> - /// Class UpdateUser - /// </summary> - [Route("/Users/{Id}/Policy", "POST", Summary = "Updates a user policy")] - [Authenticated(Roles = "admin")] - public class UpdateUserPolicy : UserPolicy, IReturnVoid - { - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class UpdateUser - /// </summary> - [Route("/Users/{Id}/Configuration", "POST", Summary = "Updates a user configuration")] - [Authenticated] - public class UpdateUserConfiguration : UserConfiguration, IReturnVoid - { - [ApiMember(Name = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public Guid Id { get; set; } - } - - /// <summary> - /// Class CreateUser - /// </summary> - [Route("/Users/New", "POST", Summary = "Creates a user")] - [Authenticated(Roles = "Admin")] - public class CreateUserByName : IReturn<UserDto> - { - [ApiMember(Name = "Name", IsRequired = true, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Name { get; set; } - - [ApiMember(Name = "Password", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Password { get; set; } - } - - [Route("/Users/ForgotPassword", "POST", Summary = "Initiates the forgot password process for a local user")] - public class ForgotPassword : IReturn<ForgotPasswordResult> - { - [ApiMember(Name = "EnteredUsername", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public string EnteredUsername { get; set; } - } - - [Route("/Users/ForgotPassword/Pin", "POST", Summary = "Redeems a forgot password pin")] - public class ForgotPasswordPin : IReturn<PinRedeemResult> - { - [ApiMember(Name = "Pin", IsRequired = false, DataType = "string", ParameterType = "body", Verb = "POST")] - public string Pin { get; set; } - } - - /// <summary> - /// Class UsersService - /// </summary> - public class UserService : BaseApiService - { - /// <summary> - /// The user manager. - /// </summary> - private readonly IUserManager _userManager; - private readonly ISessionManager _sessionMananger; - private readonly INetworkManager _networkManager; - private readonly IDeviceManager _deviceManager; - private readonly IAuthorizationContext _authContext; - - public UserService( - ILogger<UserService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IUserManager userManager, - ISessionManager sessionMananger, - INetworkManager networkManager, - IDeviceManager deviceManager, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _userManager = userManager; - _sessionMananger = sessionMananger; - _networkManager = networkManager; - _deviceManager = deviceManager; - _authContext = authContext; - } - - public object Get(GetPublicUsers request) - { - // If the startup wizard hasn't been completed then just return all users - if (!ServerConfigurationManager.Configuration.IsStartupWizardCompleted) - { - return Get(new GetUsers - { - IsDisabled = false - }); - } - - return Get(new GetUsers - { - IsHidden = false, - IsDisabled = false - }, true, true); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetUsers request) - { - return Get(request, false, false); - } - - private object Get(GetUsers request, bool filterByDevice, bool filterByNetwork) - { - var users = _userManager.Users; - - if (request.IsDisabled.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == request.IsDisabled.Value); - } - - if (request.IsHidden.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == request.IsHidden.Value); - } - - if (filterByDevice) - { - var deviceId = _authContext.GetAuthorizationInfo(Request).DeviceId; - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); - } - } - - if (filterByNetwork) - { - if (!_networkManager.IsInLocalNetwork(Request.RemoteIp)) - { - users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); - } - } - - var result = users - .OrderBy(u => u.Username) - .Select(i => _userManager.GetUserDto(i, Request.RemoteIp)) - .ToArray(); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetUser request) - { - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - var result = _userManager.GetUserDto(user, Request.RemoteIp); - - return ToOptimizedResult(result); - } - - /// <summary> - /// Deletes the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Delete(DeleteUser request) - { - return DeleteAsync(request); - } - - public Task DeleteAsync(DeleteUser request) - { - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - _sessionMananger.RevokeUserTokens(user.Id, null); - _userManager.DeleteUser(user); - return Task.CompletedTask; - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public object Post(AuthenticateUser request) - { - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - if (!string.IsNullOrEmpty(request.Password) && string.IsNullOrEmpty(request.Pw)) - { - throw new MethodNotAllowedException("Hashed-only passwords are not valid for this API."); - } - - // Password should always be null - return Post(new AuthenticateUserByName - { - Username = user.Username, - Password = null, - Pw = request.Pw - }); - } - - public async Task<object> Post(AuthenticateUserByName request) - { - var auth = _authContext.GetAuthorizationInfo(Request); - - try - { - var result = await _sessionMananger.AuthenticateNewSession(new AuthenticationRequest - { - App = auth.Client, - AppVersion = auth.Version, - DeviceId = auth.DeviceId, - DeviceName = auth.Device, - Password = request.Pw, - PasswordSha1 = request.Password, - RemoteEndPoint = Request.RemoteIp, - Username = request.Username - }).ConfigureAwait(false); - - return ToOptimizedResult(result); - } - catch (SecurityException e) - { - // rethrow adding IP address to message - throw new SecurityException($"[{Request.RemoteIp}] {e.Message}", e); - } - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public Task Post(UpdateUserPassword request) - { - return PostAsync(request); - } - - public async Task PostAsync(UpdateUserPassword request) - { - AssertCanUpdateUser(_authContext, _userManager, request.Id, true); - - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - if (request.ResetPassword) - { - await _userManager.ResetPassword(user).ConfigureAwait(false); - } - else - { - var success = await _userManager.AuthenticateUser( - user.Username, - request.CurrentPw, - request.CurrentPassword, - Request.RemoteIp, - false).ConfigureAwait(false); - - if (success == null) - { - throw new ArgumentException("Invalid user or password entered."); - } - - await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); - - var currentToken = _authContext.GetAuthorizationInfo(Request).Token; - - _sessionMananger.RevokeUserTokens(user.Id, currentToken); - } - } - - public void Post(UpdateUserEasyPassword request) - { - AssertCanUpdateUser(_authContext, _userManager, request.Id, true); - - var user = _userManager.GetUserById(request.Id); - - if (user == null) - { - throw new ResourceNotFoundException("User not found"); - } - - if (request.ResetPassword) - { - _userManager.ResetEasyPassword(user); - } - else - { - _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword); - } - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - public async Task Post(UpdateUser request) - { - var id = Guid.Parse(GetPathValue(1)); - - AssertCanUpdateUser(_authContext, _userManager, id, false); - - var dtoUser = request; - - var user = _userManager.GetUserById(id); - - if (string.Equals(user.Username, dtoUser.Name, StringComparison.Ordinal)) - { - await _userManager.UpdateUserAsync(user); - _userManager.UpdateConfiguration(user.Id, dtoUser.Configuration); - } - else - { - await _userManager.RenameUser(user, dtoUser.Name).ConfigureAwait(false); - - _userManager.UpdateConfiguration(dtoUser.Id, dtoUser.Configuration); - } - } - - /// <summary> - /// Posts the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public async Task<object> Post(CreateUserByName request) - { - var newUser = _userManager.CreateUser(request.Name); - - // no need to authenticate password for new user - if (request.Password != null) - { - await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); - } - - var result = _userManager.GetUserDto(newUser, Request.RemoteIp); - - return ToOptimizedResult(result); - } - - public async Task<object> Post(ForgotPassword request) - { - var isLocal = Request.IsLocal || _networkManager.IsInLocalNetwork(Request.RemoteIp); - - var result = await _userManager.StartForgotPasswordProcess(request.EnteredUsername, isLocal).ConfigureAwait(false); - - return result; - } - - public async Task<object> Post(ForgotPasswordPin request) - { - var result = await _userManager.RedeemPasswordResetPin(request.Pin).ConfigureAwait(false); - - return result; - } - - public void Post(UpdateUserConfiguration request) - { - AssertCanUpdateUser(_authContext, _userManager, request.Id, false); - - _userManager.UpdateConfiguration(request.Id, request); - } - - public void Post(UpdateUserPolicy request) - { - var user = _userManager.GetUserById(request.Id); - - // If removing admin access - if (!request.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) - { - if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) - { - throw new ArgumentException("There must be at least one user in the system with administrative access."); - } - } - - // If disabling - if (request.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) - { - throw new ArgumentException("Administrators cannot be disabled."); - } - - // If disabling - if (request.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) - { - if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) - { - throw new ArgumentException("There must be at least one enabled user in the system."); - } - - var currentToken = _authContext.GetAuthorizationInfo(Request).Token; - _sessionMananger.RevokeUserTokens(user.Id, currentToken); - } - - _userManager.UpdatePolicy(request.Id, request); - } - } -} diff --git a/MediaBrowser.Api/VideosService.cs b/MediaBrowser.Api/VideosService.cs deleted file mode 100644 index 957a279f8..000000000 --- a/MediaBrowser.Api/VideosService.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using System.Threading; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - [Route("/Videos/{Id}/AdditionalParts", "GET", Summary = "Gets additional parts for a video.")] - [Authenticated] - public class GetAdditionalParts : IReturn<QueryResult<BaseItemDto>> - { - [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; } - - /// <summary> - /// Gets or sets the id. - /// </summary> - /// <value>The id.</value> - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Videos/{Id}/AlternateSources", "DELETE", Summary = "Removes alternate video sources.")] - [Authenticated(Roles = "Admin")] - public class DeleteAlternateSources : IReturnVoid - { - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/Videos/MergeVersions", "POST", Summary = "Merges videos into a single record")] - [Authenticated(Roles = "Admin")] - public class MergeVersions : IReturnVoid - { - [ApiMember(Name = "Ids", Description = "Item id list. This allows multiple, comma delimited.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)] - public string Ids { get; set; } - } - - public class VideosService : BaseApiService - { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly IAuthorizationContext _authContext; - - public VideosService( - ILogger<VideosService> logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService, - IAuthorizationContext authContext) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - _authContext = authContext; - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetAdditionalParts 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); - - var dtoOptions = GetDtoOptions(_authContext, request); - - BaseItemDto[] items; - if (item is Video video) - { - items = video.GetAdditionalParts() - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) - .ToArray(); - } - else - { - items = Array.Empty<BaseItemDto>(); - } - - var result = new QueryResult<BaseItemDto> - { - Items = items, - TotalRecordCount = items.Length - }; - - return ToOptimizedResult(result); - } - - public void Delete(DeleteAlternateSources request) - { - var video = (Video)_libraryManager.GetItemById(request.Id); - - foreach (var link in video.GetLinkedAlternateVersions()) - { - link.SetPrimaryVersionId(null); - link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - - link.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - } - - video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - video.SetPrimaryVersionId(null); - video.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - } - - public void Post(MergeVersions request) - { - var items = request.Ids.Split(',') - .Select(i => _libraryManager.GetItemById(i)) - .OfType<Video>() - .OrderBy(i => i.Id) - .ToList(); - - if (items.Count < 2) - { - throw new ArgumentException("Please supply at least two videos to merge."); - } - - var videosWithVersions = items.Where(i => i.MediaSourceCount > 1) - .ToList(); - - var primaryVersion = videosWithVersions.FirstOrDefault(); - if (primaryVersion == null) - { - primaryVersion = items.OrderBy(i => - { - if (i.Video3DFormat.HasValue || i.VideoType != Model.Entities.VideoType.VideoFile) - { - return 1; - } - - return 0; - }) - .ThenByDescending(i => - { - return i.GetDefaultVideoStream()?.Width ?? 0; - }).First(); - } - - var list = primaryVersion.LinkedAlternateVersions.ToList(); - - foreach (var item in items.Where(i => i.Id != primaryVersion.Id)) - { - item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); - - item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - - list.Add(new LinkedChild - { - Path = item.Path, - ItemId = item.Id - }); - - foreach (var linkedItem in item.LinkedAlternateVersions) - { - if (!list.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) - { - list.Add(linkedItem); - } - } - - if (item.LinkedAlternateVersions.Length > 0) - { - item.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - } - } - - primaryVersion.LinkedAlternateVersions = list.ToArray(); - primaryVersion.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None); - } - } -} 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 /// <inheritdoc /> public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - static void ThrowFormatException() => throw new FormatException("Invalid format for an integer."); - ReadOnlySpan<byte> span = stackalloc byte[0]; - - if (reader.HasValueSequence) - { - long sequenceLength = reader.ValueSequence.Length; - Span<byte> stackSpan = stackalloc byte[(int)sequenceLength]; - reader.ValueSequence.CopyTo(stackSpan); - span = stackSpan; - } - else + if (reader.TokenType == JsonTokenType.String) { - span = reader.ValueSpan; - } + ReadOnlySpan<byte> 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(); } /// <inheritdoc /> public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) { - static void ThrowInvalidOperationException() => throw new InvalidOperationException(); - Span<byte> 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()); diff --git a/MediaBrowser.WebDashboard/Api/DashboardService.cs b/MediaBrowser.WebDashboard/Api/DashboardService.cs deleted file mode 100644 index 63cbfd9e4..000000000 --- a/MediaBrowser.WebDashboard/Api/DashboardService.cs +++ /dev/null @@ -1,340 +0,0 @@ -#pragma warning disable CS1591 -#pragma warning disable SA1402 -#pragma warning disable SA1649 - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Plugins; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Extensions; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Plugins; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Plugins; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.WebDashboard.Api -{ - /// <summary> - /// Class GetDashboardConfigurationPages. - /// </summary> - [Route("/web/ConfigurationPages", "GET")] - public class GetDashboardConfigurationPages : IReturn<List<ConfigurationPageInfo>> - { - /// <summary> - /// Gets or sets the type of the page. - /// </summary> - /// <value>The type of the page.</value> - public ConfigurationPageType? PageType { get; set; } - - public bool? EnableInMainMenu { get; set; } - } - - /// <summary> - /// Class GetDashboardConfigurationPage. - /// </summary> - [Route("/web/ConfigurationPage", "GET")] - public class GetDashboardConfigurationPage - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } - } - - [Route("/robots.txt", "GET", IsHidden = true)] - public class GetRobotsTxt - { - } - - /// <summary> - /// Class GetDashboardResource. - /// </summary> - [Route("/web/{ResourceName*}", "GET", IsHidden = true)] - public class GetDashboardResource - { - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string ResourceName { get; set; } - - /// <summary> - /// Gets or sets the V. - /// </summary> - /// <value>The V.</value> - public string V { get; set; } - } - - [Route("/favicon.ico", "GET", IsHidden = true)] - public class GetFavIcon - { - } - - /// <summary> - /// Class DashboardService. - /// </summary> - public class DashboardService : IService, IRequiresRequest - { - /// <summary> - /// Gets or sets the logger. - /// </summary> - /// <value>The logger.</value> - private readonly ILogger<DashboardService> _logger; - - /// <summary> - /// Gets or sets the HTTP result factory. - /// </summary> - /// <value>The HTTP result factory.</value> - private readonly IHttpResultFactory _resultFactory; - private readonly IServerApplicationHost _appHost; - private readonly IConfiguration _appConfig; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IFileSystem _fileSystem; - private readonly IResourceFileManager _resourceFileManager; - - /// <summary> - /// Initializes a new instance of the <see cref="DashboardService" /> class. - /// </summary> - /// <param name="logger">The logger.</param> - /// <param name="appHost">The application host.</param> - /// <param name="appConfig">The application configuration.</param> - /// <param name="resourceFileManager">The resource file manager.</param> - /// <param name="serverConfigurationManager">The server configuration manager.</param> - /// <param name="fileSystem">The file system.</param> - /// <param name="resultFactory">The result factory.</param> - public DashboardService( - ILogger<DashboardService> logger, - IServerApplicationHost appHost, - IConfiguration appConfig, - IResourceFileManager resourceFileManager, - IServerConfigurationManager serverConfigurationManager, - IFileSystem fileSystem, - IHttpResultFactory resultFactory) - { - _logger = logger; - _appHost = appHost; - _appConfig = appConfig; - _resourceFileManager = resourceFileManager; - _serverConfigurationManager = serverConfigurationManager; - _fileSystem = fileSystem; - _resultFactory = resultFactory; - } - - /// <summary> - /// Gets or sets the request context. - /// </summary> - /// <value>The request context.</value> - public IRequest Request { get; set; } - - /// <summary> - /// Gets the path of the directory containing the static web interface content, or null if the server is not - /// hosting the web client. - /// </summary> - public string DashboardUIPath => GetDashboardUIPath(_appConfig, _serverConfigurationManager); - - /// <summary> - /// Gets the path of the directory containing the static web interface content. - /// </summary> - /// <param name="appConfig">The app configuration.</param> - /// <param name="serverConfigManager">The server configuration manager.</param> - /// <returns>The directory path, or null if the server is not hosting the web client.</returns> - public static string GetDashboardUIPath(IConfiguration appConfig, IServerConfigurationManager serverConfigManager) - { - if (!appConfig.HostWebClient()) - { - return null; - } - - if (!string.IsNullOrEmpty(serverConfigManager.Configuration.DashboardSourcePath)) - { - return serverConfigManager.Configuration.DashboardSourcePath; - } - - return serverConfigManager.ApplicationPaths.WebPath; - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetFavIcon request) - { - return Get(new GetDashboardResource - { - ResourceName = "favicon.ico" - }); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public Task<object> Get(GetDashboardConfigurationPage request) - { - IPlugin plugin = null; - Stream stream = null; - - var isJs = false; - var isTemplate = false; - - var page = ServerEntryPoint.Instance.PluginConfigurationPages.FirstOrDefault(p => string.Equals(p.Name, request.Name, StringComparison.OrdinalIgnoreCase)); - if (page != null) - { - plugin = page.Plugin; - stream = page.GetHtmlStream(); - } - - if (plugin == null) - { - var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, request.Name, StringComparison.OrdinalIgnoreCase)); - if (altPage != null) - { - plugin = altPage.Item2; - stream = plugin.GetType().Assembly.GetManifestResourceStream(altPage.Item1.EmbeddedResourcePath); - - isJs = string.Equals(Path.GetExtension(altPage.Item1.EmbeddedResourcePath), ".js", StringComparison.OrdinalIgnoreCase); - isTemplate = altPage.Item1.EmbeddedResourcePath.EndsWith(".template.html", StringComparison.Ordinal); - } - } - - if (plugin != null && stream != null) - { - if (isJs) - { - return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.js"), () => Task.FromResult(stream)); - } - - if (isTemplate) - { - return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => Task.FromResult(stream)); - } - - return _resultFactory.GetStaticResult(Request, plugin.Version.ToString().GetMD5(), null, null, MimeTypes.GetMimeType("page.html"), () => Task.FromResult(stream)); - } - - throw new ResourceNotFoundException(); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public object Get(GetDashboardConfigurationPages request) - { - const string unavailableMessage = "The server is still loading. Please try again momentarily."; - - var instance = ServerEntryPoint.Instance; - - if (instance == null) - { - throw new InvalidOperationException(unavailableMessage); - } - - var pages = instance.PluginConfigurationPages; - - if (pages == null) - { - throw new InvalidOperationException(unavailableMessage); - } - - // Don't allow a failing plugin to fail them all - var configPages = pages.Select(p => - { - try - { - return new ConfigurationPageInfo(p); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting plugin information from {Plugin}", p.GetType().Name); - return null; - } - }) - .Where(i => i != null) - .ToList(); - - configPages.AddRange(_appHost.Plugins.SelectMany(GetConfigPages)); - - if (request.PageType.HasValue) - { - configPages = configPages.Where(p => p.ConfigurationPageType == request.PageType.Value).ToList(); - } - - if (request.EnableInMainMenu.HasValue) - { - configPages = configPages.Where(p => p.EnableInMainMenu == request.EnableInMainMenu.Value).ToList(); - } - - return configPages; - } - - private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages() - { - return _appHost.Plugins.SelectMany(GetPluginPages); - } - - private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(IPlugin plugin) - { - var hasConfig = plugin as IHasWebPages; - - if (hasConfig == null) - { - return new List<Tuple<PluginPageInfo, IPlugin>>(); - } - - return hasConfig.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin)); - } - - private IEnumerable<ConfigurationPageInfo> GetConfigPages(IPlugin plugin) - { - return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1)); - } - - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")] - public object Get(GetRobotsTxt request) - { - return Get(new GetDashboardResource - { - ResourceName = "robots.txt" - }); - } - - /// <summary> - /// Gets the specified request. - /// </summary> - /// <param name="request">The request.</param> - /// <returns>System.Object.</returns> - public async Task<object> Get(GetDashboardResource request) - { - if (!_appConfig.HostWebClient() || DashboardUIPath == null) - { - throw new ResourceNotFoundException(); - } - - var path = request?.ResourceName; - var basePath = DashboardUIPath; - - // Bounce them to the startup wizard if it hasn't been completed yet - if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted - && !Request.RawUrl.Contains("wizard", StringComparison.OrdinalIgnoreCase) - && Request.RawUrl.Contains("index", StringComparison.OrdinalIgnoreCase)) - { - Request.Response.Redirect("index.html?start=wizard#!/wizardstart.html"); - return null; - } - - return await _resultFactory.GetStaticFileResult(Request, _resourceFileManager.GetResourcePath(basePath, path)).ConfigureAwait(false); - } - } -} diff --git a/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj b/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj deleted file mode 100644 index bcaee50f2..000000000 --- a/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj +++ /dev/null @@ -1,42 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <!-- ProjectGuid is only included as a requirement for SonarQube analysis --> - <PropertyGroup> - <ProjectGuid>{5624B7B5-B5A7-41D8-9F10-CC5611109619}</ProjectGuid> - </PropertyGroup> - - <ItemGroup> - <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" /> - <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> - </ItemGroup> - - <ItemGroup> - <Compile Include="..\SharedVersion.cs" /> - </ItemGroup> - - <ItemGroup> - <None Include="jellyfin-web\**\*.*"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - </ItemGroup> - - <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <GenerateDocumentationFile>true</GenerateDocumentationFile> - <TreatWarningsAsErrors>true</TreatWarningsAsErrors> - </PropertyGroup> - - <!-- Code Analyzers--> - <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> - <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" /> - <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" /> - <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> - </ItemGroup> - - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - -</Project> diff --git a/MediaBrowser.WebDashboard/Properties/AssemblyInfo.cs b/MediaBrowser.WebDashboard/Properties/AssemblyInfo.cs deleted file mode 100644 index 584d49021..000000000 --- a/MediaBrowser.WebDashboard/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Reflection; -using System.Resources; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("MediaBrowser.WebDashboard")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Jellyfin Project")] -[assembly: AssemblyProduct("Jellyfin Server")] -[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] -[assembly: NeutralResourcesLanguage("en")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] diff --git a/MediaBrowser.WebDashboard/ServerEntryPoint.cs b/MediaBrowser.WebDashboard/ServerEntryPoint.cs deleted file mode 100644 index 5c7e8b3c7..000000000 --- a/MediaBrowser.WebDashboard/ServerEntryPoint.cs +++ /dev/null @@ -1,42 +0,0 @@ -#pragma warning disable CS1591 - -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common; -using MediaBrowser.Controller.Plugins; - -namespace MediaBrowser.WebDashboard -{ - public sealed class ServerEntryPoint : IServerEntryPoint - { - private readonly IApplicationHost _appHost; - - public ServerEntryPoint(IApplicationHost appHost) - { - _appHost = appHost; - Instance = this; - } - - public static ServerEntryPoint Instance { get; private set; } - - /// <summary> - /// Gets the list of plugin configuration pages. - /// </summary> - /// <value>The configuration pages.</value> - public List<IPluginConfigurationPage> PluginConfigurationPages { get; private set; } - - /// <inheritdoc /> - public Task RunAsync() - { - PluginConfigurationPages = _appHost.GetExports<IPluginConfigurationPage>().ToList(); - - return Task.CompletedTask; - } - - /// <inheritdoc /> - public void Dispose() - { - } - } -} diff --git a/MediaBrowser.sln b/MediaBrowser.sln index e100c0b1c..0362eff1c 100644 --- a/MediaBrowser.sln +++ b/MediaBrowser.sln @@ -12,8 +12,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Common", "Medi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Model", "MediaBrowser.Model\MediaBrowser.Model.csproj", "{7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.WebDashboard", "MediaBrowser.WebDashboard\MediaBrowser.WebDashboard.csproj", "{5624B7B5-B5A7-41D8-9F10-CC5611109619}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.Providers", "MediaBrowser.Providers\MediaBrowser.Providers.csproj", "{442B5058-DCAF-4263-BB6A-F21E31120A1B}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MediaBrowser.XbmcMetadata", "MediaBrowser.XbmcMetadata\MediaBrowser.XbmcMetadata.csproj", "{23499896-B135-4527-8574-C26E926EA99E}" @@ -94,10 +92,6 @@ Global {7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}.Debug|Any CPU.Build.0 = Debug|Any CPU {7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}.Release|Any CPU.ActiveCfg = Release|Any CPU {7EEEB4BB-F3E8-48FC-B4C5-70F0FFF8329B}.Release|Any CPU.Build.0 = Release|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5624B7B5-B5A7-41D8-9F10-CC5611109619}.Release|Any CPU.Build.0 = Release|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Debug|Any CPU.Build.0 = Debug|Any CPU {442B5058-DCAF-4263-BB6A-F21E31120A1B}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/tests/Jellyfin.Api.Tests/GetPathValueTests.cs b/tests/Jellyfin.Api.Tests/GetPathValueTests.cs index b01d1af1f..397eb2edc 100644 --- a/tests/Jellyfin.Api.Tests/GetPathValueTests.cs +++ b/tests/Jellyfin.Api.Tests/GetPathValueTests.cs @@ -31,8 +31,8 @@ namespace Jellyfin.Api.Tests var confManagerMock = Mock.Of<IServerConfigurationManager>(x => x.Configuration == conf); - var service = new BrandingService( - new NullLogger<BrandingService>(), + var service = new TestService( + new NullLogger<TestService>(), confManagerMock, Mock.Of<IHttpResultFactory>()) { diff --git a/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs b/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs index 34698fe25..5d7f7765c 100644 --- a/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs +++ b/tests/MediaBrowser.Api.Tests/BrandingServiceTests.cs @@ -43,7 +43,7 @@ namespace MediaBrowser.Api.Tests // Assert response.EnsureSuccessStatusCode(); - Assert.Equal("text/css", response.Content.Headers.ContentType.ToString()); + Assert.Equal("text/css; charset=utf-8", response.Content.Headers.ContentType.ToString()); } } } |
