aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Notifications/Api/NotificationsService.cs191
-rw-r--r--Emby.Server.Implementations/Browser/BrowserLauncher.cs4
-rw-r--r--Emby.Server.Implementations/HttpServer/Security/AuthService.cs6
-rw-r--r--Jellyfin.Api/Auth/CustomAuthenticationHandler.cs11
-rw-r--r--Jellyfin.Api/Controllers/ActivityLogController.cs57
-rw-r--r--Jellyfin.Api/Controllers/ApiKeyController.cs97
-rw-r--r--Jellyfin.Api/Controllers/ConfigurationController.cs125
-rw-r--r--Jellyfin.Api/Controllers/DevicesController.cs156
-rw-r--r--Jellyfin.Api/Controllers/NotificationsController.cs159
-rw-r--r--Jellyfin.Api/Controllers/PackageController.cs121
-rw-r--r--Jellyfin.Api/Controllers/SearchController.cs268
-rw-r--r--Jellyfin.Api/Controllers/StartupController.cs53
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs349
-rw-r--r--Jellyfin.Api/Controllers/VideoAttachmentsController.cs84
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs29
-rw-r--r--Jellyfin.Api/Jellyfin.Api.csproj3
-rw-r--r--Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs20
-rw-r--r--Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs53
-rw-r--r--Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs23
-rw-r--r--Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs22
-rw-r--r--Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs35
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs100
-rw-r--r--Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs21
-rw-r--r--Jellyfin.Server/Formatters/CssOutputFormatter.cs36
-rw-r--r--Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs23
-rw-r--r--Jellyfin.Server/Middleware/ExceptionMiddleware.cs155
-rw-r--r--Jellyfin.Server/Models/ServerCorsPolicy.cs30
-rw-r--r--Jellyfin.Server/Program.cs2
-rw-r--r--Jellyfin.Server/Startup.cs9
-rw-r--r--MediaBrowser.Api/Attachments/AttachmentService.cs63
-rw-r--r--MediaBrowser.Api/ConfigurationService.cs146
-rw-r--r--MediaBrowser.Api/PackageService.cs171
-rw-r--r--MediaBrowser.Api/SearchService.cs333
-rw-r--r--MediaBrowser.Api/Sessions/ApiKeyService.cs85
-rw-r--r--MediaBrowser.Api/Subtitles/SubtitleService.cs300
-rw-r--r--MediaBrowser.Api/System/ActivityLogService.cs66
-rw-r--r--MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs4
-rw-r--r--MediaBrowser.Common/Json/JsonDefaults.cs30
-rw-r--r--MediaBrowser.Controller/Net/AuthenticatedAttribute.cs4
-rw-r--r--tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs4
40 files changed, 2058 insertions, 1390 deletions
diff --git a/Emby.Notifications/Api/NotificationsService.cs b/Emby.Notifications/Api/NotificationsService.cs
deleted file mode 100644
index 1ff8a5026..000000000
--- a/Emby.Notifications/Api/NotificationsService.cs
+++ /dev/null
@@ -1,191 +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.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Jellyfin.Data.Enums;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Notifications;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Notifications;
-using MediaBrowser.Model.Services;
-
-namespace Emby.Notifications.Api
-{
- [Route("/Notifications/{UserId}", "GET", Summary = "Gets notifications")]
- public class GetNotifications : IReturn<NotificationResult>
- {
- [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string UserId { get; set; } = string.Empty;
-
- [ApiMember(Name = "IsRead", Description = "An optional filter by IsRead", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
- public bool? IsRead { get; set; }
-
- [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; }
-
- [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; }
- }
-
- public class Notification
- {
- public string Id { get; set; } = string.Empty;
-
- public string UserId { get; set; } = string.Empty;
-
- public DateTime Date { get; set; }
-
- public bool IsRead { get; set; }
-
- public string Name { get; set; } = string.Empty;
-
- public string Description { get; set; } = string.Empty;
-
- public string Url { get; set; } = string.Empty;
-
- public NotificationLevel Level { get; set; }
- }
-
- public class NotificationResult
- {
- public IReadOnlyList<Notification> Notifications { get; set; } = Array.Empty<Notification>();
-
- public int TotalRecordCount { get; set; }
- }
-
- public class NotificationsSummary
- {
- public int UnreadCount { get; set; }
-
- public NotificationLevel MaxUnreadNotificationLevel { get; set; }
- }
-
- [Route("/Notifications/{UserId}/Summary", "GET", Summary = "Gets a notification summary for a user")]
- public class GetNotificationsSummary : IReturn<NotificationsSummary>
- {
- [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string UserId { get; set; } = string.Empty;
- }
-
- [Route("/Notifications/Types", "GET", Summary = "Gets notification types")]
- public class GetNotificationTypes : IReturn<List<NotificationTypeInfo>>
- {
- }
-
- [Route("/Notifications/Services", "GET", Summary = "Gets notification types")]
- public class GetNotificationServices : IReturn<List<NameIdPair>>
- {
- }
-
- [Route("/Notifications/Admin", "POST", Summary = "Sends a notification to all admin users")]
- public class AddAdminNotification : IReturnVoid
- {
- [ApiMember(Name = "Name", Description = "The notification's name", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
- public string Name { get; set; } = string.Empty;
-
- [ApiMember(Name = "Description", Description = "The notification's description", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
- public string Description { get; set; } = string.Empty;
-
- [ApiMember(Name = "ImageUrl", Description = "The notification's image url", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
- public string? ImageUrl { get; set; }
-
- [ApiMember(Name = "Url", Description = "The notification's info url", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
- public string? Url { get; set; }
-
- [ApiMember(Name = "Level", Description = "The notification level", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
- public NotificationLevel Level { get; set; }
- }
-
- [Route("/Notifications/{UserId}/Read", "POST", Summary = "Marks notifications as read")]
- public class MarkRead : IReturnVoid
- {
- [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
- public string UserId { get; set; } = string.Empty;
-
- [ApiMember(Name = "Ids", Description = "A list of notification ids, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)]
- public string Ids { get; set; } = string.Empty;
- }
-
- [Route("/Notifications/{UserId}/Unread", "POST", Summary = "Marks notifications as unread")]
- public class MarkUnread : IReturnVoid
- {
- [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
- public string UserId { get; set; } = string.Empty;
-
- [ApiMember(Name = "Ids", Description = "A list of notification ids, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)]
- public string Ids { get; set; } = string.Empty;
- }
-
- [Authenticated]
- public class NotificationsService : IService
- {
- private readonly INotificationManager _notificationManager;
- private readonly IUserManager _userManager;
-
- public NotificationsService(INotificationManager notificationManager, IUserManager userManager)
- {
- _notificationManager = notificationManager;
- _userManager = userManager;
- }
-
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
- public object Get(GetNotificationTypes request)
- {
- return _notificationManager.GetNotificationTypes();
- }
-
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
- public object Get(GetNotificationServices request)
- {
- return _notificationManager.GetNotificationServices().ToList();
- }
-
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
- public object Get(GetNotificationsSummary request)
- {
- return new NotificationsSummary();
- }
-
- public Task Post(AddAdminNotification request)
- {
- // This endpoint really just exists as post of a real with sickbeard
- var notification = new NotificationRequest
- {
- Date = DateTime.UtcNow,
- Description = request.Description,
- Level = request.Level,
- Name = request.Name,
- Url = request.Url,
- UserIds = _userManager.Users
- .Where(user => user.HasPermission(PermissionKind.IsAdministrator))
- .Select(user => user.Id)
- .ToArray()
- };
-
- return _notificationManager.SendNotification(notification, CancellationToken.None);
- }
-
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
- public void Post(MarkRead request)
- {
- }
-
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
- public void Post(MarkUnread request)
- {
- }
-
- [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "request", Justification = "Required for ServiceStack")]
- public object Get(GetNotifications request)
- {
- return new NotificationResult();
- }
- }
-}
diff --git a/Emby.Server.Implementations/Browser/BrowserLauncher.cs b/Emby.Server.Implementations/Browser/BrowserLauncher.cs
index 7a0294e07..f8108d1c2 100644
--- a/Emby.Server.Implementations/Browser/BrowserLauncher.cs
+++ b/Emby.Server.Implementations/Browser/BrowserLauncher.cs
@@ -1,5 +1,7 @@
using System;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Browser
@@ -24,7 +26,7 @@ namespace Emby.Server.Implementations.Browser
/// <param name="appHost">The app host.</param>
public static void OpenSwaggerPage(IServerApplicationHost appHost)
{
- TryOpenUrl(appHost, "/swagger/index.html");
+ TryOpenUrl(appHost, "/api-docs/swagger");
}
/// <summary>
diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
index 072e94cdd..c65d4694a 100644
--- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
+++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
@@ -140,11 +140,17 @@ namespace Emby.Server.Implementations.HttpServer.Security
{
return true;
}
+
if (authAttribtues.AllowLocalOnly && request.IsLocal)
{
return true;
}
+ if (authAttribtues.IgnoreLegacyAuth)
+ {
+ return true;
+ }
+
return false;
}
diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
index 767ba9fd4..a5c4e9974 100644
--- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
+++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
@@ -39,13 +39,20 @@ namespace Jellyfin.Api.Auth
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
- var authenticatedAttribute = new AuthenticatedAttribute();
+ var authenticatedAttribute = new AuthenticatedAttribute
+ {
+ IgnoreLegacyAuth = true
+ };
+
try
{
var user = _authService.Authenticate(Request, authenticatedAttribute);
if (user == null)
{
- return Task.FromResult(AuthenticateResult.Fail("Invalid user"));
+ return Task.FromResult(AuthenticateResult.NoResult());
+ // TODO return when legacy API is removed.
+ // Don't spam the log with "Invalid User"
+ // return Task.FromResult(AuthenticateResult.Fail("Invalid user"));
}
var claims = new[]
diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs
new file mode 100644
index 000000000..895d9f719
--- /dev/null
+++ b/Jellyfin.Api/Controllers/ActivityLogController.cs
@@ -0,0 +1,57 @@
+#nullable enable
+#pragma warning disable CA1801
+
+using System;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Activity log controller.
+ /// </summary>
+ [Route("/System/ActivityLog")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ public class ActivityLogController : BaseJellyfinApiController
+ {
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ActivityLogController"/> class.
+ /// </summary>
+ /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param>
+ public ActivityLogController(IActivityManager activityManager)
+ {
+ _activityManager = activityManager;
+ }
+
+ /// <summary>
+ /// Gets activity log entries.
+ /// </summary>
+ /// <param name="startIndex">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="minDate">Optional. The minimum date. Format = ISO.</param>
+ /// <param name="hasUserId">Optional. Only returns activities that have a user associated.</param>
+ /// <response code="200">Activity log returned.</response>
+ /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
+ [HttpGet("Entries")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<ActivityLogEntry>> GetLogEntries(
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] DateTime? minDate,
+ bool? hasUserId)
+ {
+ var filterFunc = new Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>>(
+ entries => entries.Where(entry => entry.DateCreated >= minDate));
+
+ return _activityManager.GetPagedResult(filterFunc, startIndex, limit);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs
new file mode 100644
index 000000000..ed521c1fc
--- /dev/null
+++ b/Jellyfin.Api/Controllers/ApiKeyController.cs
@@ -0,0 +1,97 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Security;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Authentication controller.
+ /// </summary>
+ [Route("/Auth")]
+ public class ApiKeyController : BaseJellyfinApiController
+ {
+ private readonly ISessionManager _sessionManager;
+ private readonly IServerApplicationHost _appHost;
+ private readonly IAuthenticationRepository _authRepo;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ApiKeyController"/> class.
+ /// </summary>
+ /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+ /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
+ /// <param name="authRepo">Instance of <see cref="IAuthenticationRepository"/> interface.</param>
+ public ApiKeyController(
+ ISessionManager sessionManager,
+ IServerApplicationHost appHost,
+ IAuthenticationRepository authRepo)
+ {
+ _sessionManager = sessionManager;
+ _appHost = appHost;
+ _authRepo = authRepo;
+ }
+
+ /// <summary>
+ /// Get all keys.
+ /// </summary>
+ /// <response code="200">Api keys retrieved.</response>
+ /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns>
+ [HttpGet("Keys")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<AuthenticationInfo>> GetKeys()
+ {
+ var result = _authRepo.Get(new AuthenticationInfoQuery
+ {
+ HasUser = false
+ });
+
+ return result;
+ }
+
+ /// <summary>
+ /// Create a new api key.
+ /// </summary>
+ /// <param name="app">Name of the app using the authentication key.</param>
+ /// <response code="204">Api key created.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("Keys")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult CreateKey([FromQuery, Required] string app)
+ {
+ _authRepo.Create(new AuthenticationInfo
+ {
+ AppName = app,
+ AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
+ DateCreated = DateTime.UtcNow,
+ DeviceId = _appHost.SystemId,
+ DeviceName = _appHost.FriendlyName,
+ AppVersion = _appHost.ApplicationVersionString
+ });
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Remove an api key.
+ /// </summary>
+ /// <param name="key">The access token to delete.</param>
+ /// <response code="204">Api key deleted.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("Keys/{key}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult RevokeKey([FromRoute] string key)
+ {
+ _sessionManager.RevokeToken(key);
+ return NoContent();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs
new file mode 100644
index 000000000..780a38aa8
--- /dev/null
+++ b/Jellyfin.Api/Controllers/ConfigurationController.cs
@@ -0,0 +1,125 @@
+#nullable enable
+
+using System.Text.Json;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.ConfigurationDtos;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Configuration;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Configuration Controller.
+ /// </summary>
+ [Route("System")]
+ [Authorize]
+ public class ConfigurationController : BaseJellyfinApiController
+ {
+ private readonly IServerConfigurationManager _configurationManager;
+ private readonly IMediaEncoder _mediaEncoder;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ConfigurationController"/> class.
+ /// </summary>
+ /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+ public ConfigurationController(
+ IServerConfigurationManager configurationManager,
+ IMediaEncoder mediaEncoder)
+ {
+ _configurationManager = configurationManager;
+ _mediaEncoder = mediaEncoder;
+ }
+
+ /// <summary>
+ /// Gets application configuration.
+ /// </summary>
+ /// <response code="200">Application configuration returned.</response>
+ /// <returns>Application configuration.</returns>
+ [HttpGet("Configuration")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<ServerConfiguration> GetConfiguration()
+ {
+ return _configurationManager.Configuration;
+ }
+
+ /// <summary>
+ /// Updates application configuration.
+ /// </summary>
+ /// <param name="configuration">Configuration.</param>
+ /// <response code="204">Configuration updated.</response>
+ /// <returns>Update status.</returns>
+ [HttpPost("Configuration")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult UpdateConfiguration([FromBody, BindRequired] ServerConfiguration configuration)
+ {
+ _configurationManager.ReplaceConfiguration(configuration);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Gets a named configuration.
+ /// </summary>
+ /// <param name="key">Configuration key.</param>
+ /// <response code="200">Configuration returned.</response>
+ /// <returns>Configuration.</returns>
+ [HttpGet("Configuration/{Key}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<object> GetNamedConfiguration([FromRoute] string key)
+ {
+ return _configurationManager.GetConfiguration(key);
+ }
+
+ /// <summary>
+ /// Updates named configuration.
+ /// </summary>
+ /// <param name="key">Configuration key.</param>
+ /// <response code="204">Named configuration updated.</response>
+ /// <returns>Update status.</returns>
+ [HttpPost("Configuration/{Key}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string key)
+ {
+ var configurationType = _configurationManager.GetConfigurationType(key);
+ var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType).ConfigureAwait(false);
+ _configurationManager.SaveConfiguration(key, configuration);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Gets a default MetadataOptions object.
+ /// </summary>
+ /// <response code="200">Metadata options returned.</response>
+ /// <returns>Default MetadataOptions.</returns>
+ [HttpGet("Configuration/MetadataOptions/Default")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<MetadataOptions> GetDefaultMetadataOptions()
+ {
+ return new MetadataOptions();
+ }
+
+ /// <summary>
+ /// Updates the path to the media encoder.
+ /// </summary>
+ /// <param name="mediaEncoderPath">Media encoder path form body.</param>
+ /// <response code="204">Media encoder path updated.</response>
+ /// <returns>Status.</returns>
+ [HttpPost("MediaEncoder/Path")]
+ [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult UpdateMediaEncoderPath([FromForm, BindRequired] MediaEncoderPathDto mediaEncoderPath)
+ {
+ _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
+ return NoContent();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs
new file mode 100644
index 000000000..1754b0cbd
--- /dev/null
+++ b/Jellyfin.Api/Controllers/DevicesController.cs
@@ -0,0 +1,156 @@
+#nullable enable
+
+using System;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Security;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Devices;
+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>
+ /// Devices Controller.
+ /// </summary>
+ [Authorize]
+ public class DevicesController : BaseJellyfinApiController
+ {
+ private readonly IDeviceManager _deviceManager;
+ private readonly IAuthenticationRepository _authenticationRepository;
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DevicesController"/> class.
+ /// </summary>
+ /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
+ /// <param name="authenticationRepository">Instance of <see cref="IAuthenticationRepository"/> interface.</param>
+ /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+ public DevicesController(
+ IDeviceManager deviceManager,
+ IAuthenticationRepository authenticationRepository,
+ ISessionManager sessionManager)
+ {
+ _deviceManager = deviceManager;
+ _authenticationRepository = authenticationRepository;
+ _sessionManager = sessionManager;
+ }
+
+ /// <summary>
+ /// Get Devices.
+ /// </summary>
+ /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param>
+ /// <param name="userId">Gets or sets the user identifier.</param>
+ /// <response code="200">Devices retrieved.</response>
+ /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
+ [HttpGet]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
+ {
+ var deviceQuery = new DeviceQuery { SupportsSync = supportsSync, UserId = userId ?? Guid.Empty };
+ return _deviceManager.GetDevices(deviceQuery);
+ }
+
+ /// <summary>
+ /// Get info for a device.
+ /// </summary>
+ /// <param name="id">Device Id.</param>
+ /// <response code="200">Device info retrieved.</response>
+ /// <response code="404">Device not found.</response>
+ /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+ [HttpGet("Info")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, BindRequired] string id)
+ {
+ var deviceInfo = _deviceManager.GetDevice(id);
+ if (deviceInfo == null)
+ {
+ return NotFound();
+ }
+
+ return deviceInfo;
+ }
+
+ /// <summary>
+ /// Get options for a device.
+ /// </summary>
+ /// <param name="id">Device Id.</param>
+ /// <response code="200">Device options retrieved.</response>
+ /// <response code="404">Device not found.</response>
+ /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+ [HttpGet("Options")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, BindRequired] string id)
+ {
+ var deviceInfo = _deviceManager.GetDeviceOptions(id);
+ if (deviceInfo == null)
+ {
+ return NotFound();
+ }
+
+ return deviceInfo;
+ }
+
+ /// <summary>
+ /// Update device options.
+ /// </summary>
+ /// <param name="id">Device Id.</param>
+ /// <param name="deviceOptions">Device Options.</param>
+ /// <response code="204">Device options updated.</response>
+ /// <response code="404">Device not found.</response>
+ /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+ [HttpPost("Options")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult UpdateDeviceOptions(
+ [FromQuery, BindRequired] string id,
+ [FromBody, BindRequired] DeviceOptions deviceOptions)
+ {
+ var existingDeviceOptions = _deviceManager.GetDeviceOptions(id);
+ if (existingDeviceOptions == null)
+ {
+ return NotFound();
+ }
+
+ _deviceManager.UpdateDeviceOptions(id, deviceOptions);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Deletes a device.
+ /// </summary>
+ /// <param name="id">Device Id.</param>
+ /// <response code="204">Device deleted.</response>
+ /// <response code="404">Device not found.</response>
+ /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+ [HttpDelete]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult DeleteDevice([FromQuery, BindRequired] string id)
+ {
+ var existingDevice = _deviceManager.GetDevice(id);
+ if (existingDevice == null)
+ {
+ return NotFound();
+ }
+
+ var sessions = _authenticationRepository.Get(new AuthenticationInfoQuery { DeviceId = id }).Items;
+
+ foreach (var session in sessions)
+ {
+ _sessionManager.Logout(session);
+ }
+
+ return NoContent();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs
new file mode 100644
index 000000000..5af194756
--- /dev/null
+++ b/Jellyfin.Api/Controllers/NotificationsController.cs
@@ -0,0 +1,159 @@
+#nullable enable
+#pragma warning disable CA1801
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Api.Models.NotificationDtos;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Notifications;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Notifications;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// The notification controller.
+ /// </summary>
+ public class NotificationsController : BaseJellyfinApiController
+ {
+ private readonly INotificationManager _notificationManager;
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NotificationsController" /> class.
+ /// </summary>
+ /// <param name="notificationManager">The notification manager.</param>
+ /// <param name="userManager">The user manager.</param>
+ public NotificationsController(INotificationManager notificationManager, IUserManager userManager)
+ {
+ _notificationManager = notificationManager;
+ _userManager = userManager;
+ }
+
+ /// <summary>
+ /// Gets a user's notifications.
+ /// </summary>
+ /// <param name="userId">The user's ID.</param>
+ /// <param name="isRead">An optional filter by notification read state.</param>
+ /// <param name="startIndex">The optional index to start at. All notifications with a lower index will be omitted from the results.</param>
+ /// <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}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<NotificationResultDto> GetNotifications(
+ [FromRoute] string userId,
+ [FromQuery] bool? isRead,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit)
+ {
+ return new NotificationResultDto();
+ }
+
+ /// <summary>
+ /// Gets a user's notification summary.
+ /// </summary>
+ /// <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")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<NotificationsSummaryDto> GetNotificationsSummary(
+ [FromRoute] string userId)
+ {
+ return new NotificationsSummaryDto();
+ }
+
+ /// <summary>
+ /// Gets notification types.
+ /// </summary>
+ /// <response code="200">All notification types returned.</response>
+ /// <returns>An <cref see="OkResult"/> containing a list of all notification types.</returns>
+ [HttpGet("Types")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public IEnumerable<NotificationTypeInfo> GetNotificationTypes()
+ {
+ return _notificationManager.GetNotificationTypes();
+ }
+
+ /// <summary>
+ /// Gets notification services.
+ /// </summary>
+ /// <response code="200">All notification services returned.</response>
+ /// <returns>An <cref see="OkResult"/> containing a list of all notification services.</returns>
+ [HttpGet("Services")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public IEnumerable<NameIdPair> GetNotificationServices()
+ {
+ return _notificationManager.GetNotificationServices();
+ }
+
+ /// <summary>
+ /// Sends a notification to all admins.
+ /// </summary>
+ /// <param name="name">The name of the notification.</param>
+ /// <param name="description">The description of the notification.</param>
+ /// <param name="url">The URL of the notification.</param>
+ /// <param name="level">The level of the notification.</param>
+ /// <response code="204">Notification sent.</response>
+ /// <returns>A <cref see="NoContentResult"/>.</returns>
+ [HttpPost("Admin")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult CreateAdminNotification(
+ [FromQuery] string name,
+ [FromQuery] string description,
+ [FromQuery] string? url,
+ [FromQuery] NotificationLevel? level)
+ {
+ var notification = new NotificationRequest
+ {
+ Name = name,
+ Description = description,
+ Url = url,
+ Level = level ?? NotificationLevel.Normal,
+ UserIds = _userManager.Users.Where(i => i.Policy.IsAdministrator).Select(i => i.Id).ToArray(),
+ Date = DateTime.UtcNow,
+ };
+
+ _notificationManager.SendNotification(notification, CancellationToken.None);
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Sets notifications as read.
+ /// </summary>
+ /// <param name="userId">The userID.</param>
+ /// <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")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SetRead(
+ [FromRoute] string userId,
+ [FromQuery] string ids)
+ {
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Sets notifications as unread.
+ /// </summary>
+ /// <param name="userId">The userID.</param>
+ /// <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")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SetUnread(
+ [FromRoute] string userId,
+ [FromQuery] string ids)
+ {
+ return NoContent();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs
new file mode 100644
index 000000000..8200f891c
--- /dev/null
+++ b/Jellyfin.Api/Controllers/PackageController.cs
@@ -0,0 +1,121 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Model.Updates;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Package Controller.
+ /// </summary>
+ [Route("Packages")]
+ [Authorize]
+ public class PackageController : BaseJellyfinApiController
+ {
+ private readonly IInstallationManager _installationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PackageController"/> class.
+ /// </summary>
+ /// <param name="installationManager">Instance of <see cref="IInstallationManager"/>Installation Manager.</param>
+ public PackageController(IInstallationManager installationManager)
+ {
+ _installationManager = installationManager;
+ }
+
+ /// <summary>
+ /// Gets a package by name or assembly GUID.
+ /// </summary>
+ /// <param name="name">The name of the package.</param>
+ /// <param name="assemblyGuid">The GUID of the associated assembly.</param>
+ /// <returns>A <see cref="PackageInfo"/> containing package information.</returns>
+ [HttpGet("/{Name}")]
+ [ProducesResponseType(typeof(PackageInfo), StatusCodes.Status200OK)]
+ public async Task<ActionResult<PackageInfo>> GetPackageInfo(
+ [FromRoute] [Required] string name,
+ [FromQuery] string? assemblyGuid)
+ {
+ var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
+ var result = _installationManager.FilterPackages(
+ packages,
+ name,
+ string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid)).FirstOrDefault();
+
+ return result;
+ }
+
+ /// <summary>
+ /// Gets available packages.
+ /// </summary>
+ /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns>
+ [HttpGet]
+ [ProducesResponseType(typeof(PackageInfo[]), StatusCodes.Status200OK)]
+ public async Task<IEnumerable<PackageInfo>> GetPackages()
+ {
+ IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
+
+ return packages;
+ }
+
+ /// <summary>
+ /// Installs a package.
+ /// </summary>
+ /// <param name="name">Package name.</param>
+ /// <param name="assemblyGuid">GUID of the associated assembly.</param>
+ /// <param name="version">Optional version. Defaults to latest version.</param>
+ /// <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}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ public async Task<ActionResult> InstallPackage(
+ [FromRoute] [Required] string name,
+ [FromQuery] string assemblyGuid,
+ [FromQuery] string version)
+ {
+ var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
+ var package = _installationManager.GetCompatibleVersions(
+ packages,
+ name,
+ string.IsNullOrEmpty(assemblyGuid) ? Guid.Empty : Guid.Parse(assemblyGuid),
+ string.IsNullOrEmpty(version) ? null : Version.Parse(version)).FirstOrDefault();
+
+ if (package == null)
+ {
+ return NotFound();
+ }
+
+ await _installationManager.InstallPackage(package).ConfigureAwait(false);
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Cancels a package installation.
+ /// </summary>
+ /// <param name="id">Installation Id.</param>
+ /// <response code="204">Installation cancelled.</response>
+ /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns>
+ [HttpDelete("/Installing/{id}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public IActionResult CancelPackageInstallation(
+ [FromRoute] [Required] string id)
+ {
+ _installationManager.CancelInstallation(new Guid(id));
+
+ return NoContent();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs
new file mode 100644
index 000000000..ec05e4fb4
--- /dev/null
+++ b/Jellyfin.Api/Controllers/SearchController.cs
@@ -0,0 +1,268 @@
+using System;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Search;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Search controller.
+ /// </summary>
+ [Route("/Search/Hints")]
+ [Authorize]
+ public class SearchController : BaseJellyfinApiController
+ {
+ private readonly ISearchEngine _searchEngine;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IDtoService _dtoService;
+ private readonly IImageProcessor _imageProcessor;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SearchController"/> class.
+ /// </summary>
+ /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param>
+ /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
+ /// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
+ public SearchController(
+ ISearchEngine searchEngine,
+ ILibraryManager libraryManager,
+ IDtoService dtoService,
+ IImageProcessor imageProcessor)
+ {
+ _searchEngine = searchEngine;
+ _libraryManager = libraryManager;
+ _dtoService = dtoService;
+ _imageProcessor = imageProcessor;
+ }
+
+ /// <summary>
+ /// Gets the search hint result.
+ /// </summary>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="userId">Optional. Supply a user id to search within a user's library or omit to search all.</param>
+ /// <param name="searchTerm">The search term to filter on.</param>
+ /// <param name="includeItemTypes">If specified, only results with the specified item types are returned. This allows multiple, comma delimeted.</param>
+ /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimeted.</param>
+ /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimeted.</param>
+ /// <param name="parentId">If specified, only children of the parent are returned.</param>
+ /// <param name="isMovie">Optional filter for movies.</param>
+ /// <param name="isSeries">Optional filter for series.</param>
+ /// <param name="isNews">Optional filter for news.</param>
+ /// <param name="isKids">Optional filter for kids.</param>
+ /// <param name="isSports">Optional filter for sports.</param>
+ /// <param name="includePeople">Optional filter whether to include people.</param>
+ /// <param name="includeMedia">Optional filter whether to include media.</param>
+ /// <param name="includeGenres">Optional filter whether to include genres.</param>
+ /// <param name="includeStudios">Optional filter whether to include studios.</param>
+ /// <param name="includeArtists">Optional filter whether to include artists.</param>
+ /// <response code="200">Search hint returned.</response>
+ /// <returns>An <see cref="SearchHintResult"/> with the results of the search.</returns>
+ [HttpGet]
+ [Description("Gets search hints based on a search term")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<SearchHintResult> Get(
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] Guid userId,
+ [FromQuery, Required] string searchTerm,
+ [FromQuery] string includeItemTypes,
+ [FromQuery] string excludeItemTypes,
+ [FromQuery] string mediaTypes,
+ [FromQuery] string parentId,
+ [FromQuery] bool? isMovie,
+ [FromQuery] bool? isSeries,
+ [FromQuery] bool? isNews,
+ [FromQuery] bool? isKids,
+ [FromQuery] bool? isSports,
+ [FromQuery] bool includePeople = true,
+ [FromQuery] bool includeMedia = true,
+ [FromQuery] bool includeGenres = true,
+ [FromQuery] bool includeStudios = true,
+ [FromQuery] bool includeArtists = true)
+ {
+ var result = _searchEngine.GetSearchHints(new SearchQuery
+ {
+ Limit = limit,
+ SearchTerm = searchTerm,
+ IncludeArtists = includeArtists,
+ IncludeGenres = includeGenres,
+ IncludeMedia = includeMedia,
+ IncludePeople = includePeople,
+ IncludeStudios = includeStudios,
+ StartIndex = startIndex,
+ UserId = userId,
+ IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+ ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+ MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+ ParentId = parentId,
+
+ IsKids = isKids,
+ IsMovie = isMovie,
+ IsNews = isNews,
+ IsSeries = isSeries,
+ IsSports = isSports
+ });
+
+ return new SearchHintResult
+ {
+ TotalRecordCount = result.TotalRecordCount,
+ SearchHints = result.Items.Select(GetSearchHintResult).ToArray()
+ };
+ }
+
+ /// <summary>
+ /// Gets the search hint result.
+ /// </summary>
+ /// <param name="hintInfo">The hint info.</param>
+ /// <returns>SearchHintResult.</returns>
+ private SearchHint GetSearchHintResult(SearchHintInfo hintInfo)
+ {
+ var item = hintInfo.Item;
+
+ var result = new SearchHint
+ {
+ Name = item.Name,
+ IndexNumber = item.IndexNumber,
+ ParentIndexNumber = item.ParentIndexNumber,
+ Id = item.Id,
+ Type = item.GetClientTypeName(),
+ MediaType = item.MediaType,
+ MatchedTerm = hintInfo.MatchedTerm,
+ RunTimeTicks = item.RunTimeTicks,
+ ProductionYear = item.ProductionYear,
+ ChannelId = item.ChannelId,
+ EndDate = item.EndDate
+ };
+
+ // legacy
+ result.ItemId = result.Id;
+
+ if (item.IsFolder)
+ {
+ result.IsFolder = true;
+ }
+
+ var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary);
+
+ if (primaryImageTag != null)
+ {
+ result.PrimaryImageTag = primaryImageTag;
+ result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item);
+ }
+
+ SetThumbImageInfo(result, item);
+ SetBackdropImageInfo(result, item);
+
+ switch (item)
+ {
+ case IHasSeries hasSeries:
+ result.Series = hasSeries.SeriesName;
+ break;
+ case LiveTvProgram program:
+ result.StartDate = program.StartDate;
+ break;
+ case Series series:
+ if (series.Status.HasValue)
+ {
+ result.Status = series.Status.Value.ToString();
+ }
+
+ break;
+ case MusicAlbum album:
+ result.Artists = album.Artists;
+ result.AlbumArtist = album.AlbumArtist;
+ break;
+ case Audio song:
+ result.AlbumArtist = song.AlbumArtists?[0];
+ result.Artists = song.Artists;
+
+ MusicAlbum musicAlbum = song.AlbumEntity;
+
+ if (musicAlbum != null)
+ {
+ result.Album = musicAlbum.Name;
+ result.AlbumId = musicAlbum.Id;
+ }
+ else
+ {
+ result.Album = song.Album;
+ }
+
+ break;
+ }
+
+ if (!item.ChannelId.Equals(Guid.Empty))
+ {
+ var channel = _libraryManager.GetItemById(item.ChannelId);
+ result.ChannelName = channel?.Name;
+ }
+
+ return result;
+ }
+
+ private void SetThumbImageInfo(SearchHint hint, BaseItem item)
+ {
+ var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null;
+
+ if (itemWithImage == null && item is Episode)
+ {
+ itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb);
+ }
+
+ if (itemWithImage == null)
+ {
+ itemWithImage = GetParentWithImage<BaseItem>(item, ImageType.Thumb);
+ }
+
+ if (itemWithImage != null)
+ {
+ var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb);
+
+ if (tag != null)
+ {
+ hint.ThumbImageTag = tag;
+ hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture);
+ }
+ }
+ }
+
+ private void SetBackdropImageInfo(SearchHint hint, BaseItem item)
+ {
+ var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null)
+ ?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop);
+
+ if (itemWithImage != null)
+ {
+ var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop);
+
+ if (tag != null)
+ {
+ hint.BackdropImageTag = tag;
+ hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture);
+ }
+ }
+ }
+
+ private T GetParentWithImage<T>(BaseItem item, ImageType type)
+ where T : BaseItem
+ {
+ return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type));
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs
index 6ec0a4e26..d96b0f993 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -5,6 +5,7 @@ using Jellyfin.Api.Models.StartupDtos;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Api.Controllers
@@ -30,22 +31,28 @@ namespace Jellyfin.Api.Controllers
}
/// <summary>
- /// Api endpoint for completing the startup wizard.
+ /// Completes the startup wizard.
/// </summary>
+ /// <response code="204">Startup wizard completed.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Complete")]
- public void CompleteWizard()
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult CompleteWizard()
{
_config.Configuration.IsStartupWizardCompleted = true;
_config.SetOptimalValues();
_config.SaveConfiguration();
+ return NoContent();
}
/// <summary>
- /// Endpoint for getting the initial startup wizard configuration.
+ /// Gets the initial startup wizard configuration.
/// </summary>
- /// <returns>The initial startup wizard configuration.</returns>
+ /// <response code="200">Initial startup wizard configuration retrieved.</response>
+ /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns>
[HttpGet("Configuration")]
- public StartupConfigurationDto GetStartupConfiguration()
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<StartupConfigurationDto> GetStartupConfiguration()
{
var result = new StartupConfigurationDto
{
@@ -58,13 +65,16 @@ namespace Jellyfin.Api.Controllers
}
/// <summary>
- /// Endpoint for updating the initial startup wizard configuration.
+ /// Sets the initial startup wizard configuration.
/// </summary>
/// <param name="uiCulture">The UI language culture.</param>
/// <param name="metadataCountryCode">The metadata country code.</param>
/// <param name="preferredMetadataLanguage">The preferred language for metadata.</param>
+ /// <response code="204">Configuration saved.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Configuration")]
- public void UpdateInitialConfiguration(
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult UpdateInitialConfiguration(
[FromForm] string uiCulture,
[FromForm] string metadataCountryCode,
[FromForm] string preferredMetadataLanguage)
@@ -73,27 +83,35 @@ namespace Jellyfin.Api.Controllers
_config.Configuration.MetadataCountryCode = metadataCountryCode;
_config.Configuration.PreferredMetadataLanguage = preferredMetadataLanguage;
_config.SaveConfiguration();
+ return NoContent();
}
/// <summary>
- /// Endpoint for (dis)allowing remote access and UPnP.
+ /// Sets remote access and UPnP.
/// </summary>
/// <param name="enableRemoteAccess">Enable remote access.</param>
/// <param name="enableAutomaticPortMapping">Enable UPnP.</param>
+ /// <response code="204">Configuration saved.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("RemoteAccess")]
- public void SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping)
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping)
{
_config.Configuration.EnableRemoteAccess = enableRemoteAccess;
_config.Configuration.EnableUPnP = enableAutomaticPortMapping;
_config.SaveConfiguration();
+ return NoContent();
}
/// <summary>
- /// Endpoint for returning the first user.
+ /// Gets the first user.
/// </summary>
+ /// <response code="200">Initial user retrieved.</response>
/// <returns>The first user.</returns>
[HttpGet("User")]
- public StartupUserDto GetFirstUser()
+ [HttpGet("FirstUser")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<StartupUserDto> GetFirstUser()
{
// TODO: Remove this method when startup wizard no longer requires an existing user.
_userManager.Initialize();
@@ -106,12 +124,17 @@ namespace Jellyfin.Api.Controllers
}
/// <summary>
- /// Endpoint for updating the user name and password.
+ /// Sets the user name and password.
/// </summary>
/// <param name="startupUserDto">The DTO containing username and password.</param>
- /// <returns>The async task.</returns>
+ /// <response code="204">Updated user name and password.</response>
+ /// <returns>
+ /// A <see cref="Task" /> that represents the asynchronous update operation.
+ /// The task result contains a <see cref="NoContentResult"/> indicating success.
+ /// </returns>
[HttpPost("User")]
- public async Task UpdateUser([FromForm] StartupUserDto startupUserDto)
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> UpdateUser([FromForm] StartupUserDto startupUserDto)
{
var user = _userManager.Users.First();
@@ -123,6 +146,8 @@ namespace Jellyfin.Api.Controllers
{
await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
}
+
+ return NoContent();
}
}
}
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
new file mode 100644
index 000000000..97df8c60d
--- /dev/null
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -0,0 +1,349 @@
+#nullable enable
+#pragma warning disable CA1801
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Providers;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Subtitle controller.
+ /// </summary>
+ public class SubtitleController : BaseJellyfinApiController
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly ISubtitleManager _subtitleManager;
+ private readonly ISubtitleEncoder _subtitleEncoder;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IProviderManager _providerManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly IAuthorizationContext _authContext;
+ private readonly ILogger<SubtitleController> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SubtitleController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param>
+ /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param>
+ /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param>
+ /// <param name="authContext">Instance of <see cref="IAuthorizationContext"/> interface.</param>
+ /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param>
+ public SubtitleController(
+ ILibraryManager libraryManager,
+ ISubtitleManager subtitleManager,
+ ISubtitleEncoder subtitleEncoder,
+ IMediaSourceManager mediaSourceManager,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ IAuthorizationContext authContext,
+ ILogger<SubtitleController> logger)
+ {
+ _libraryManager = libraryManager;
+ _subtitleManager = subtitleManager;
+ _subtitleEncoder = subtitleEncoder;
+ _mediaSourceManager = mediaSourceManager;
+ _providerManager = providerManager;
+ _fileSystem = fileSystem;
+ _authContext = authContext;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Deletes an external subtitle file.
+ /// </summary>
+ /// <param name="id">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}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<Task> DeleteSubtitle(
+ [FromRoute] Guid id,
+ [FromRoute] int index)
+ {
+ var item = _libraryManager.GetItemById(id);
+
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ _subtitleManager.DeleteSubtitles(item, index);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Search remote subtitles.
+ /// </summary>
+ /// <param name="id">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}")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
+ [FromRoute] Guid id,
+ [FromRoute] string language,
+ [FromQuery] bool? isPerfectMatch)
+ {
+ var video = (Video)_libraryManager.GetItemById(id);
+
+ return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Downloads a remote subtitle.
+ /// </summary>
+ /// <param name="id">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}")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> DownloadRemoteSubtitles(
+ [FromRoute] Guid id,
+ [FromRoute] string subtitleId)
+ {
+ var video = (Video)_libraryManager.GetItemById(id);
+
+ try
+ {
+ await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error downloading subtitles");
+ }
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Gets the remote subtitles.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <response code="200">File returned.</response>
+ /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns>
+ [HttpGet("/Providers/Subtitles/Subtitles/{id}")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Produces(MediaTypeNames.Application.Octet)]
+ public async Task<ActionResult> GetRemoteSubtitles([FromRoute] string id)
+ {
+ var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
+
+ return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format));
+ }
+
+ /// <summary>
+ /// Gets subtitles in a specified format.
+ /// </summary>
+ /// <param name="id">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>
+ /// <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}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult> GetSubtitle(
+ [FromRoute, Required] Guid id,
+ [FromRoute, Required] string mediaSourceId,
+ [FromRoute, Required] int index,
+ [FromRoute, Required] string format,
+ [FromRoute] long startPositionTicks,
+ [FromQuery] long? endPositionTicks,
+ [FromQuery] bool copyTimestamps,
+ [FromQuery] bool addVttTimeMap)
+ {
+ if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
+ {
+ format = "json";
+ }
+
+ if (string.IsNullOrEmpty(format))
+ {
+ var item = (Video)_libraryManager.GetItemById(id);
+
+ var idString = id.ToString("N", CultureInfo.InvariantCulture);
+ var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false)
+ .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal));
+
+ var subtitleStream = mediaSource.MediaStreams
+ .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index);
+
+ FileStream stream = new FileStream(subtitleStream.Path, FileMode.Open, FileAccess.Read);
+ return File(stream, MimeTypes.GetMimeType(subtitleStream.Path));
+ }
+
+ if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
+ {
+ await using Stream stream = await EncodeSubtitles(id, mediaSourceId, index, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false);
+ using var reader = new StreamReader(stream);
+
+ var text = await reader.ReadToEndAsync().ConfigureAwait(false);
+
+ text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal);
+
+ return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format));
+ }
+
+ return File(
+ await EncodeSubtitles(
+ id,
+ mediaSourceId,
+ index,
+ format,
+ startPositionTicks,
+ endPositionTicks,
+ copyTimestamps).ConfigureAwait(false),
+ MimeTypes.GetMimeType("file." + format));
+ }
+
+ /// <summary>
+ /// Gets an HLS subtitle playlist.
+ /// </summary>
+ /// <param name="id">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")]
+ [Authorize]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult> GetSubtitlePlaylist(
+ [FromRoute] Guid id,
+ // TODO: 'int index' is never used: CA1801 is disabled
+ [FromRoute] int index,
+ [FromRoute] string mediaSourceId,
+ [FromQuery, Required] int segmentLength)
+ {
+ var item = (Video)_libraryManager.GetItemById(id);
+
+ var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false);
+
+ var builder = new StringBuilder();
+
+ var runtime = mediaSource.RunTimeTicks ?? -1;
+
+ if (runtime <= 0)
+ {
+ throw new ArgumentException("HLS Subtitles are not supported for this media.");
+ }
+
+ var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks;
+ if (segmentLengthTicks <= 0)
+ {
+ throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)");
+ }
+
+ builder.AppendLine("#EXTM3U");
+ builder.AppendLine("#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture));
+ builder.AppendLine("#EXT-X-VERSION:3");
+ builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0");
+ builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
+
+ long positionTicks = 0;
+
+ var accessToken = _authContext.GetAuthorizationInfo(Request).Token;
+
+ while (positionTicks < runtime)
+ {
+ var remaining = runtime - positionTicks;
+ var lengthTicks = Math.Min(remaining, segmentLengthTicks);
+
+ builder.AppendLine("#EXTINF:" + TimeSpan.FromTicks(lengthTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture) + ",");
+
+ var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks);
+
+ var url = string.Format(
+ CultureInfo.CurrentCulture,
+ "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}",
+ positionTicks.ToString(CultureInfo.InvariantCulture),
+ endPositionTicks.ToString(CultureInfo.InvariantCulture),
+ accessToken);
+
+ builder.AppendLine(url);
+
+ positionTicks += segmentLengthTicks;
+ }
+
+ builder.AppendLine("#EXT-X-ENDLIST");
+ return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
+ }
+
+ /// <summary>
+ /// Encodes a subtitle in the specified format.
+ /// </summary>
+ /// <param name="id">The media id.</param>
+ /// <param name="mediaSourceId">The source media id.</param>
+ /// <param name="index">The subtitle index.</param>
+ /// <param name="format">The format to convert to.</param>
+ /// <param name="startPositionTicks">The start position in ticks.</param>
+ /// <param name="endPositionTicks">The end position in ticks.</param>
+ /// <param name="copyTimestamps">Whether to copy the timestamps.</param>
+ /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns>
+ private Task<Stream> EncodeSubtitles(
+ Guid id,
+ string mediaSourceId,
+ int index,
+ string format,
+ long startPositionTicks,
+ long? endPositionTicks,
+ bool copyTimestamps)
+ {
+ var item = _libraryManager.GetItemById(id);
+
+ return _subtitleEncoder.GetSubtitles(
+ item,
+ mediaSourceId,
+ index,
+ format,
+ startPositionTicks,
+ endPositionTicks ?? 0,
+ copyTimestamps,
+ CancellationToken.None);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
new file mode 100644
index 000000000..86d9322fe
--- /dev/null
+++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
@@ -0,0 +1,84 @@
+#nullable enable
+
+using System;
+using System.Net.Mime;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Attachments controller.
+ /// </summary>
+ [Route("Videos")]
+ [Authorize]
+ public class VideoAttachmentsController : BaseJellyfinApiController
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IAttachmentExtractor _attachmentExtractor;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="VideoAttachmentsController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param>
+ public VideoAttachmentsController(
+ ILibraryManager libraryManager,
+ IAttachmentExtractor attachmentExtractor)
+ {
+ _libraryManager = libraryManager;
+ _attachmentExtractor = attachmentExtractor;
+ }
+
+ /// <summary>
+ /// Get video attachment.
+ /// </summary>
+ /// <param name="videoId">Video ID.</param>
+ /// <param name="mediaSourceId">Media Source ID.</param>
+ /// <param name="index">Attachment Index.</param>
+ /// <response code="200">Attachment retrieved.</response>
+ /// <response code="404">Video or attachment not found.</response>
+ /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns>
+ [HttpGet("{VideoID}/{MediaSourceID}/Attachments/{Index}")]
+ [Produces(MediaTypeNames.Application.Octet)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<FileStreamResult>> GetAttachment(
+ [FromRoute] Guid videoId,
+ [FromRoute] string mediaSourceId,
+ [FromRoute] int index)
+ {
+ try
+ {
+ var item = _libraryManager.GetItemById(videoId);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ var (attachment, stream) = await _attachmentExtractor.GetAttachment(
+ item,
+ mediaSourceId,
+ index,
+ CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var contentType = string.IsNullOrWhiteSpace(attachment.MimeType)
+ ? MediaTypeNames.Application.Octet
+ : attachment.MimeType;
+
+ return new FileStreamResult(stream, contentType);
+ }
+ catch (ResourceNotFoundException e)
+ {
+ return NotFound(e.Message);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
new file mode 100644
index 000000000..9f4d34f9c
--- /dev/null
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -0,0 +1,29 @@
+using System;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// Request Extensions.
+ /// </summary>
+ public static class RequestHelpers
+ {
+ /// <summary>
+ /// Splits a string at a separating character into an array of substrings.
+ /// </summary>
+ /// <param name="value">The string to split.</param>
+ /// <param name="separator">The char that separates the substrings.</param>
+ /// <param name="removeEmpty">Option to remove empty substrings from the array.</param>
+ /// <returns>An array of the substrings.</returns>
+ internal static string[] Split(string value, char separator, bool removeEmpty)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return Array.Empty<string>();
+ }
+
+ return removeEmpty
+ ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries)
+ : value.Split(separator);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj
index f3016bb3e..57af29a92 100644
--- a/Jellyfin.Api/Jellyfin.Api.csproj
+++ b/Jellyfin.Api/Jellyfin.Api.csproj
@@ -16,7 +16,8 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="3.1.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
- <PackageReference Include="Swashbuckle.AspNetCore" Version="5.0.0" />
+ <PackageReference Include="Swashbuckle.AspNetCore" Version="5.3.3" />
+ <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="5.3.3" />
</ItemGroup>
<ItemGroup>
diff --git a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
new file mode 100644
index 000000000..3706a11e3
--- /dev/null
+++ b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
@@ -0,0 +1,20 @@
+#nullable enable
+
+namespace Jellyfin.Api.Models.ConfigurationDtos
+{
+ /// <summary>
+ /// Media Encoder Path Dto.
+ /// </summary>
+ public class MediaEncoderPathDto
+ {
+ /// <summary>
+ /// Gets or sets media encoder path.
+ /// </summary>
+ public string Path { get; set; } = null!;
+
+ /// <summary>
+ /// Gets or sets media encoder path type.
+ /// </summary>
+ public string PathType { get; set; } = null!;
+ }
+}
diff --git a/Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs b/Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs
new file mode 100644
index 000000000..502b22623
--- /dev/null
+++ b/Jellyfin.Api/Models/NotificationDtos/NotificationDto.cs
@@ -0,0 +1,53 @@
+#nullable enable
+
+using System;
+using MediaBrowser.Model.Notifications;
+
+namespace Jellyfin.Api.Models.NotificationDtos
+{
+ /// <summary>
+ /// The notification DTO.
+ /// </summary>
+ public class NotificationDto
+ {
+ /// <summary>
+ /// Gets or sets the notification ID. Defaults to an empty string.
+ /// </summary>
+ public string Id { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the notification's user ID. Defaults to an empty string.
+ /// </summary>
+ public string UserId { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the notification date.
+ /// </summary>
+ public DateTime Date { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the notification has been read. Defaults to false.
+ /// </summary>
+ public bool IsRead { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets the notification's name. Defaults to an empty string.
+ /// </summary>
+ public string Name { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the notification's description. Defaults to an empty string.
+ /// </summary>
+ public string Description { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the notification's URL. Defaults to an empty string.
+ /// </summary>
+ public string Url { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the notification level.
+ /// </summary>
+ public NotificationLevel Level { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs b/Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs
new file mode 100644
index 000000000..e34e176cb
--- /dev/null
+++ b/Jellyfin.Api/Models/NotificationDtos/NotificationResultDto.cs
@@ -0,0 +1,23 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Api.Models.NotificationDtos
+{
+ /// <summary>
+ /// A list of notifications with the total record count for pagination.
+ /// </summary>
+ public class NotificationResultDto
+ {
+ /// <summary>
+ /// Gets or sets the current page of notifications.
+ /// </summary>
+ public IReadOnlyList<NotificationDto> Notifications { get; set; } = Array.Empty<NotificationDto>();
+
+ /// <summary>
+ /// Gets or sets the total number of notifications.
+ /// </summary>
+ public int TotalRecordCount { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs b/Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs
new file mode 100644
index 000000000..b3746ee2d
--- /dev/null
+++ b/Jellyfin.Api/Models/NotificationDtos/NotificationsSummaryDto.cs
@@ -0,0 +1,22 @@
+#nullable enable
+
+using MediaBrowser.Model.Notifications;
+
+namespace Jellyfin.Api.Models.NotificationDtos
+{
+ /// <summary>
+ /// The notification summary DTO.
+ /// </summary>
+ public class NotificationsSummaryDto
+ {
+ /// <summary>
+ /// Gets or sets the number of unread notifications.
+ /// </summary>
+ public int UnreadCount { get; set; }
+
+ /// <summary>
+ /// Gets or sets the maximum unread notification level.
+ /// </summary>
+ public NotificationLevel? MaxUnreadNotificationLevel { get; set; }
+ }
+}
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index db06eb455..745567703 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -1,3 +1,4 @@
+using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Builder;
namespace Jellyfin.Server.Extensions
@@ -11,17 +12,39 @@ namespace Jellyfin.Server.Extensions
/// Adds swagger and swagger UI to the application pipeline.
/// </summary>
/// <param name="applicationBuilder">The application builder.</param>
+ /// <param name="serverConfigurationManager">The server configuration.</param>
/// <returns>The updated application builder.</returns>
- public static IApplicationBuilder UseJellyfinApiSwagger(this IApplicationBuilder applicationBuilder)
+ public static IApplicationBuilder UseJellyfinApiSwagger(
+ this IApplicationBuilder applicationBuilder,
+ IServerConfigurationManager serverConfigurationManager)
{
- applicationBuilder.UseSwagger();
-
// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.),
// specifying the Swagger JSON endpoint.
- return applicationBuilder.UseSwaggerUI(c =>
+
+ var baseUrl = serverConfigurationManager.Configuration.BaseUrl.Trim('/');
+ if (!string.IsNullOrEmpty(baseUrl))
{
- c.SwaggerEndpoint("/swagger/v1/swagger.json", "Jellyfin API V1");
- });
+ baseUrl += '/';
+ }
+
+ return applicationBuilder
+ .UseSwagger(c =>
+ {
+ // Custom path requires {documentName}, SwaggerDoc documentName is 'api-docs'
+ c.RouteTemplate = $"/{baseUrl}{{documentName}}/openapi.json";
+ })
+ .UseSwaggerUI(c =>
+ {
+ c.DocumentTitle = "Jellyfin API";
+ c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API");
+ c.RoutePrefix = $"{baseUrl}api-docs/swagger";
+ })
+ .UseReDoc(c =>
+ {
+ c.DocumentTitle = "Jellyfin API";
+ c.SpecUrl($"/{baseUrl}api-docs/openapi.json");
+ c.RoutePrefix = $"{baseUrl}api-docs/redoc";
+ });
}
}
}
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index 71ef9a69a..9cdaa0eb1 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -1,13 +1,23 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
using Jellyfin.Api;
using Jellyfin.Api.Auth;
using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy;
using Jellyfin.Api.Auth.RequiresElevationPolicy;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Controllers;
+using Jellyfin.Server.Formatters;
+using Jellyfin.Server.Models;
+using MediaBrowser.Common.Json;
+using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Extensions
{
@@ -63,9 +73,18 @@ namespace Jellyfin.Server.Extensions
/// <returns>The MVC builder.</returns>
public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl)
{
- return serviceCollection.AddMvc(opts =>
+ return serviceCollection
+ .AddCors(options =>
+ {
+ options.AddPolicy(ServerCorsPolicy.DefaultPolicyName, ServerCorsPolicy.DefaultPolicy);
+ })
+ .AddMvc(opts =>
{
opts.UseGeneralRoutePrefix(baseUrl);
+ opts.OutputFormatters.Insert(0, new CamelCaseJsonProfileFormatter());
+ opts.OutputFormatters.Insert(0, new PascalCaseJsonProfileFormatter());
+
+ opts.OutputFormatters.Add(new CssOutputFormatter());
})
// Clear app parts to avoid other assemblies being picked up
@@ -73,8 +92,20 @@ namespace Jellyfin.Server.Extensions
.AddApplicationPart(typeof(StartupController).Assembly)
.AddJsonOptions(options =>
{
- // Setting the naming policy to null leaves the property names as-is when serializing objects to JSON.
- options.JsonSerializerOptions.PropertyNamingPolicy = null;
+ // Update all properties that are set in JsonDefaults
+ var jsonOptions = JsonDefaults.GetPascalCaseOptions();
+
+ // From JsonDefaults
+ options.JsonSerializerOptions.ReadCommentHandling = jsonOptions.ReadCommentHandling;
+ options.JsonSerializerOptions.WriteIndented = jsonOptions.WriteIndented;
+ options.JsonSerializerOptions.Converters.Clear();
+ foreach (var converter in jsonOptions.Converters)
+ {
+ options.JsonSerializerOptions.Converters.Add(converter);
+ }
+
+ // From JsonDefaults.PascalCase
+ options.JsonSerializerOptions.PropertyNamingPolicy = jsonOptions.PropertyNamingPolicy;
})
.AddControllersAsServices();
}
@@ -88,8 +119,69 @@ namespace Jellyfin.Server.Extensions
{
return serviceCollection.AddSwaggerGen(c =>
{
- c.SwaggerDoc("v1", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" });
+ c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" });
+ c.AddSecurityDefinition(AuthenticationSchemes.CustomAuthentication, new OpenApiSecurityScheme
+ {
+ Type = SecuritySchemeType.ApiKey,
+ In = ParameterLocation.Header,
+ Name = "X-Emby-Token",
+ Description = "API key header parameter"
+ });
+
+ var securitySchemeRef = new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = AuthenticationSchemes.CustomAuthentication },
+ };
+
+ // TODO: Apply this with an operation filter instead of globally
+ // https://github.com/domaindrivendev/Swashbuckle.AspNetCore#add-security-definitions-and-requirements
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ { securitySchemeRef, Array.Empty<string>() }
+ });
+
+ // Add all xml doc files to swagger generator.
+ var xmlFiles = Directory.GetFiles(
+ AppContext.BaseDirectory,
+ "*.xml",
+ SearchOption.TopDirectoryOnly);
+
+ foreach (var xmlFile in xmlFiles)
+ {
+ c.IncludeXmlComments(xmlFile);
+ }
+
+ // Order actions by route path, then by http method.
+ c.OrderActionsBy(description =>
+ $"{description.ActionDescriptor.RouteValues["controller"]}_{description.HttpMethod}");
+
+ // Use method name as operationId
+ c.CustomOperationIds(description =>
+ description.TryGetMethodInfo(out MethodInfo methodInfo) ? methodInfo.Name : null);
+
+ // TODO - remove when all types are supported in System.Text.Json
+ c.AddSwaggerTypeMappings();
});
}
+
+ private static void AddSwaggerTypeMappings(this SwaggerGenOptions options)
+ {
+ /*
+ * TODO remove when System.Text.Json supports non-string keys.
+ * Used in Jellyfin.Api.Controller.GetChannels.
+ */
+ options.MapType<Dictionary<ImageType, string>>(() =>
+ new OpenApiSchema
+ {
+ Type = "object",
+ Properties = typeof(ImageType).GetEnumNames().ToDictionary(
+ name => name,
+ name => new OpenApiSchema
+ {
+ Type = "string",
+ Format = "string"
+ })
+ });
+ }
}
}
diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs
new file mode 100644
index 000000000..9b347ae2c
--- /dev/null
+++ b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs
@@ -0,0 +1,21 @@
+using MediaBrowser.Common.Json;
+using Microsoft.AspNetCore.Mvc.Formatters;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Server.Formatters
+{
+ /// <summary>
+ /// Camel Case Json Profile Formatter.
+ /// </summary>
+ public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class.
+ /// </summary>
+ public CamelCaseJsonProfileFormatter() : base(JsonDefaults.GetCamelCaseOptions())
+ {
+ SupportedMediaTypes.Clear();
+ SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"CamelCase\""));
+ }
+ }
+}
diff --git a/Jellyfin.Server/Formatters/CssOutputFormatter.cs b/Jellyfin.Server/Formatters/CssOutputFormatter.cs
new file mode 100644
index 000000000..b3771b7fe
--- /dev/null
+++ b/Jellyfin.Server/Formatters/CssOutputFormatter.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.Formatters;
+
+namespace Jellyfin.Server.Formatters
+{
+ /// <summary>
+ /// Css output formatter.
+ /// </summary>
+ public class CssOutputFormatter : TextOutputFormatter
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class.
+ /// </summary>
+ public CssOutputFormatter()
+ {
+ SupportedMediaTypes.Add("text/css");
+
+ SupportedEncodings.Add(Encoding.UTF8);
+ SupportedEncodings.Add(Encoding.Unicode);
+ }
+
+ /// <summary>
+ /// Write context object to stream.
+ /// </summary>
+ /// <param name="context">Writer context.</param>
+ /// <param name="selectedEncoding">Unused. Writer encoding.</param>
+ /// <returns>Write stream task.</returns>
+ public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
+ {
+ return context.HttpContext.Response.WriteAsync(context.Object?.ToString());
+ }
+ }
+}
diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs
new file mode 100644
index 000000000..0024708ba
--- /dev/null
+++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs
@@ -0,0 +1,23 @@
+using MediaBrowser.Common.Json;
+using Microsoft.AspNetCore.Mvc.Formatters;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.Server.Formatters
+{
+ /// <summary>
+ /// Pascal Case Json Profile Formatter.
+ /// </summary>
+ public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class.
+ /// </summary>
+ public PascalCaseJsonProfileFormatter() : base(JsonDefaults.GetPascalCaseOptions())
+ {
+ SupportedMediaTypes.Clear();
+ // Add application/json for default formatter
+ SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json"));
+ SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"PascalCase\""));
+ }
+ }
+}
diff --git a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs
new file mode 100644
index 000000000..63effafc1
--- /dev/null
+++ b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs
@@ -0,0 +1,155 @@
+using System;
+using System.IO;
+using System.Net.Mime;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Net;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Middleware
+{
+ /// <summary>
+ /// Exception Middleware.
+ /// </summary>
+ public class ExceptionMiddleware
+ {
+ private readonly RequestDelegate _next;
+ private readonly ILogger<ExceptionMiddleware> _logger;
+ private readonly IServerConfigurationManager _configuration;
+ private readonly IWebHostEnvironment _hostEnvironment;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class.
+ /// </summary>
+ /// <param name="next">Next request delegate.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ /// <param name="hostEnvironment">Instance of the <see cref="IWebHostEnvironment"/> interface.</param>
+ public ExceptionMiddleware(
+ RequestDelegate next,
+ ILogger<ExceptionMiddleware> logger,
+ IServerConfigurationManager serverConfigurationManager,
+ IWebHostEnvironment hostEnvironment)
+ {
+ _next = next;
+ _logger = logger;
+ _configuration = serverConfigurationManager;
+ _hostEnvironment = hostEnvironment;
+ }
+
+ /// <summary>
+ /// Invoke request.
+ /// </summary>
+ /// <param name="context">Request context.</param>
+ /// <returns>Task.</returns>
+ public async Task Invoke(HttpContext context)
+ {
+ try
+ {
+ await _next(context).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ if (context.Response.HasStarted)
+ {
+ _logger.LogWarning("The response has already started, the exception middleware will not be executed.");
+ throw;
+ }
+
+ ex = GetActualException(ex);
+
+ bool ignoreStackTrace =
+ ex is SocketException
+ || ex is IOException
+ || ex is OperationCanceledException
+ || ex is SecurityException
+ || ex is AuthenticationException
+ || ex is FileNotFoundException;
+
+ if (ignoreStackTrace)
+ {
+ _logger.LogError(
+ "Error processing request: {ExceptionMessage}. URL {Method} {Url}.",
+ ex.Message.TrimEnd('.'),
+ context.Request.Method,
+ context.Request.Path);
+ }
+ else
+ {
+ _logger.LogError(
+ ex,
+ "Error processing request. URL {Method} {Url}.",
+ context.Request.Method,
+ context.Request.Path);
+ }
+
+ context.Response.StatusCode = GetStatusCode(ex);
+ context.Response.ContentType = MediaTypeNames.Text.Plain;
+
+ // Don't send exception unless the server is in a Development environment
+ var errorContent = _hostEnvironment.IsDevelopment()
+ ? NormalizeExceptionMessage(ex.Message)
+ : "Error processing request.";
+ await context.Response.WriteAsync(errorContent).ConfigureAwait(false);
+ }
+ }
+
+ private static Exception GetActualException(Exception ex)
+ {
+ if (ex is AggregateException agg)
+ {
+ var inner = agg.InnerException;
+ if (inner != null)
+ {
+ return GetActualException(inner);
+ }
+
+ var inners = agg.InnerExceptions;
+ if (inners.Count > 0)
+ {
+ return GetActualException(inners[0]);
+ }
+ }
+
+ return ex;
+ }
+
+ private static int GetStatusCode(Exception ex)
+ {
+ switch (ex)
+ {
+ case ArgumentException _: return StatusCodes.Status400BadRequest;
+ case SecurityException _: return StatusCodes.Status401Unauthorized;
+ case DirectoryNotFoundException _:
+ case FileNotFoundException _:
+ case ResourceNotFoundException _: return StatusCodes.Status404NotFound;
+ case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed;
+ default: return StatusCodes.Status500InternalServerError;
+ }
+ }
+
+ private string NormalizeExceptionMessage(string msg)
+ {
+ if (msg == null)
+ {
+ return string.Empty;
+ }
+
+ // Strip any information we don't want to reveal
+ return msg.Replace(
+ _configuration.ApplicationPaths.ProgramSystemPath,
+ string.Empty,
+ StringComparison.OrdinalIgnoreCase)
+ .Replace(
+ _configuration.ApplicationPaths.ProgramDataPath,
+ string.Empty,
+ StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Models/ServerCorsPolicy.cs b/Jellyfin.Server/Models/ServerCorsPolicy.cs
new file mode 100644
index 000000000..ae010c042
--- /dev/null
+++ b/Jellyfin.Server/Models/ServerCorsPolicy.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Cors.Infrastructure;
+
+namespace Jellyfin.Server.Models
+{
+ /// <summary>
+ /// Server Cors Policy.
+ /// </summary>
+ public static class ServerCorsPolicy
+ {
+ /// <summary>
+ /// Default policy name.
+ /// </summary>
+ public const string DefaultPolicyName = "DefaultCorsPolicy";
+
+ /// <summary>
+ /// Default Policy. Allow Everything.
+ /// </summary>
+ public static readonly CorsPolicy DefaultPolicy = new CorsPolicy
+ {
+ // Allow any origin
+ Origins = { "*" },
+
+ // Allow any method
+ Methods = { "*" },
+
+ // Allow any header
+ Headers = { "*" }
+ };
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index 7c693f8c3..3971a08e9 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -570,7 +570,7 @@ namespace Jellyfin.Server
var inMemoryDefaultConfig = ConfigurationOptions.DefaultConfiguration;
if (startupConfig != null && !startupConfig.HostWebClient())
{
- inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "swagger/index.html";
+ inMemoryDefaultConfig[HttpListenerHost.DefaultRedirectKey] = "api-docs/swagger";
}
return config
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 5f9a5c161..a7bc15614 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -1,4 +1,6 @@
using Jellyfin.Server.Extensions;
+using Jellyfin.Server.Middleware;
+using Jellyfin.Server.Models;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Builder;
@@ -59,15 +61,18 @@ namespace Jellyfin.Server
app.UseDeveloperExceptionPage();
}
+ app.UseMiddleware<ExceptionMiddleware>();
+
app.UseWebSockets();
app.UseResponseCompression();
// TODO app.UseMiddleware<WebSocketMiddleware>();
- // TODO use when old API is removed: app.UseAuthentication();
- app.UseJellyfinApiSwagger();
+ app.UseAuthentication();
+ app.UseJellyfinApiSwagger(_serverConfigurationManager);
app.UseRouting();
+ app.UseCors(ServerCorsPolicy.DefaultPolicyName);
app.UseAuthorization();
if (_serverConfigurationManager.Configuration.EnableMetrics)
{
diff --git a/MediaBrowser.Api/Attachments/AttachmentService.cs b/MediaBrowser.Api/Attachments/AttachmentService.cs
deleted file mode 100644
index 1632ca1b0..000000000
--- a/MediaBrowser.Api/Attachments/AttachmentService.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.Attachments
-{
- [Route("/Videos/{Id}/{MediaSourceId}/Attachments/{Index}", "GET", Summary = "Gets specified attachment.")]
- public class GetAttachment
- {
- [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public Guid Id { get; set; }
-
- [ApiMember(Name = "MediaSourceId", Description = "MediaSourceId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string MediaSourceId { get; set; }
-
- [ApiMember(Name = "Index", Description = "The attachment stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")]
- public int Index { get; set; }
- }
-
- public class AttachmentService : BaseApiService
- {
- private readonly ILibraryManager _libraryManager;
- private readonly IAttachmentExtractor _attachmentExtractor;
-
- public AttachmentService(
- ILogger<AttachmentService> logger,
- IServerConfigurationManager serverConfigurationManager,
- IHttpResultFactory httpResultFactory,
- ILibraryManager libraryManager,
- IAttachmentExtractor attachmentExtractor)
- : base(logger, serverConfigurationManager, httpResultFactory)
- {
- _libraryManager = libraryManager;
- _attachmentExtractor = attachmentExtractor;
- }
-
- public async Task<object> Get(GetAttachment request)
- {
- var (attachment, attachmentStream) = await GetAttachment(request).ConfigureAwait(false);
- var mime = string.IsNullOrWhiteSpace(attachment.MimeType) ? "application/octet-stream" : attachment.MimeType;
-
- return ResultFactory.GetResult(Request, attachmentStream, mime);
- }
-
- private Task<(MediaAttachment, Stream)> GetAttachment(GetAttachment request)
- {
- var item = _libraryManager.GetItemById(request.Id);
-
- return _attachmentExtractor.GetAttachment(item,
- request.MediaSourceId,
- request.Index,
- CancellationToken.None);
- }
- }
-}
diff --git a/MediaBrowser.Api/ConfigurationService.cs b/MediaBrowser.Api/ConfigurationService.cs
deleted file mode 100644
index 316be04a0..000000000
--- a/MediaBrowser.Api/ConfigurationService.cs
+++ /dev/null
@@ -1,146 +0,0 @@
-using System.IO;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Configuration;
-using MediaBrowser.Model.Serialization;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api
-{
- /// <summary>
- /// Class GetConfiguration
- /// </summary>
- [Route("/System/Configuration", "GET", Summary = "Gets application configuration")]
- [Authenticated]
- public class GetConfiguration : IReturn<ServerConfiguration>
- {
-
- }
-
- [Route("/System/Configuration/{Key}", "GET", Summary = "Gets a named configuration")]
- [Authenticated(AllowBeforeStartupWizard = true)]
- public class GetNamedConfiguration
- {
- [ApiMember(Name = "Key", Description = "Key", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string Key { get; set; }
- }
-
- /// <summary>
- /// Class UpdateConfiguration
- /// </summary>
- [Route("/System/Configuration", "POST", Summary = "Updates application configuration")]
- [Authenticated(Roles = "Admin")]
- public class UpdateConfiguration : ServerConfiguration, IReturnVoid
- {
- }
-
- [Route("/System/Configuration/{Key}", "POST", Summary = "Updates named configuration")]
- [Authenticated(Roles = "Admin")]
- public class UpdateNamedConfiguration : IReturnVoid, IRequiresRequestStream
- {
- [ApiMember(Name = "Key", Description = "Key", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string Key { get; set; }
-
- public Stream RequestStream { get; set; }
- }
-
- [Route("/System/Configuration/MetadataOptions/Default", "GET", Summary = "Gets a default MetadataOptions object")]
- [Authenticated(Roles = "Admin")]
- public class GetDefaultMetadataOptions : IReturn<MetadataOptions>
- {
-
- }
-
- [Route("/System/MediaEncoder/Path", "POST", Summary = "Updates the path to the media encoder")]
- [Authenticated(Roles = "Admin", AllowBeforeStartupWizard = true)]
- public class UpdateMediaEncoderPath : IReturnVoid
- {
- [ApiMember(Name = "Path", Description = "Path", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
- public string Path { get; set; }
- [ApiMember(Name = "PathType", Description = "PathType", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
- public string PathType { get; set; }
- }
-
- public class ConfigurationService : BaseApiService
- {
- /// <summary>
- /// The _json serializer
- /// </summary>
- private readonly IJsonSerializer _jsonSerializer;
-
- /// <summary>
- /// The _configuration manager
- /// </summary>
- private readonly IServerConfigurationManager _configurationManager;
-
- private readonly IMediaEncoder _mediaEncoder;
-
- public ConfigurationService(
- ILogger<ConfigurationService> logger,
- IServerConfigurationManager serverConfigurationManager,
- IHttpResultFactory httpResultFactory,
- IJsonSerializer jsonSerializer,
- IServerConfigurationManager configurationManager,
- IMediaEncoder mediaEncoder)
- : base(logger, serverConfigurationManager, httpResultFactory)
- {
- _jsonSerializer = jsonSerializer;
- _configurationManager = configurationManager;
- _mediaEncoder = mediaEncoder;
- }
-
- public void Post(UpdateMediaEncoderPath request)
- {
- _mediaEncoder.UpdateEncoderPath(request.Path, request.PathType);
- }
-
- /// <summary>
- /// Gets the specified request.
- /// </summary>
- /// <param name="request">The request.</param>
- /// <returns>System.Object.</returns>
- public object Get(GetConfiguration request)
- {
- return ToOptimizedResult(_configurationManager.Configuration);
- }
-
- public object Get(GetNamedConfiguration request)
- {
- var result = _configurationManager.GetConfiguration(request.Key);
-
- return ToOptimizedResult(result);
- }
-
- /// <summary>
- /// Posts the specified configuraiton.
- /// </summary>
- /// <param name="request">The request.</param>
- public void Post(UpdateConfiguration request)
- {
- // Silly, but we need to serialize and deserialize or the XmlSerializer will write the xml with an element name of UpdateConfiguration
- var json = _jsonSerializer.SerializeToString(request);
-
- var config = _jsonSerializer.DeserializeFromString<ServerConfiguration>(json);
-
- _configurationManager.ReplaceConfiguration(config);
- }
-
- public async Task Post(UpdateNamedConfiguration request)
- {
- var key = GetPathValue(2).ToString();
-
- var configurationType = _configurationManager.GetConfigurationType(key);
- var configuration = await _jsonSerializer.DeserializeFromStreamAsync(request.RequestStream, configurationType).ConfigureAwait(false);
-
- _configurationManager.SaveConfiguration(key, configuration);
- }
-
- public object Get(GetDefaultMetadataOptions request)
- {
- return ToOptimizedResult(new MetadataOptions());
- }
- }
-}
diff --git a/MediaBrowser.Api/PackageService.cs b/MediaBrowser.Api/PackageService.cs
deleted file mode 100644
index 444354a99..000000000
--- a/MediaBrowser.Api/PackageService.cs
+++ /dev/null
@@ -1,171 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Updates;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Services;
-using MediaBrowser.Model.Updates;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api
-{
- /// <summary>
- /// Class GetPackage
- /// </summary>
- [Route("/Packages/{Name}", "GET", Summary = "Gets a package, by name or assembly guid")]
- [Authenticated]
- public class GetPackage : IReturn<PackageInfo>
- {
- /// <summary>
- /// Gets or sets the name.
- /// </summary>
- /// <value>The name.</value>
- [ApiMember(Name = "Name", Description = "The name of the package", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string Name { get; set; }
-
- /// <summary>
- /// Gets or sets the name.
- /// </summary>
- /// <value>The name.</value>
- [ApiMember(Name = "AssemblyGuid", Description = "The guid of the associated assembly", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
- public string AssemblyGuid { get; set; }
- }
-
- /// <summary>
- /// Class GetPackages
- /// </summary>
- [Route("/Packages", "GET", Summary = "Gets available packages")]
- [Authenticated]
- public class GetPackages : IReturn<PackageInfo[]>
- {
- }
-
- /// <summary>
- /// Class InstallPackage
- /// </summary>
- [Route("/Packages/Installed/{Name}", "POST", Summary = "Installs a package")]
- [Authenticated(Roles = "Admin")]
- public class InstallPackage : IReturnVoid
- {
- /// <summary>
- /// Gets or sets the name.
- /// </summary>
- /// <value>The name.</value>
- [ApiMember(Name = "Name", Description = "Package name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
- public string Name { get; set; }
-
- /// <summary>
- /// Gets or sets the name.
- /// </summary>
- /// <value>The name.</value>
- [ApiMember(Name = "AssemblyGuid", Description = "Guid of the associated assembly", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
- public string AssemblyGuid { get; set; }
-
- /// <summary>
- /// Gets or sets the version.
- /// </summary>
- /// <value>The version.</value>
- [ApiMember(Name = "Version", Description = "Optional version. Defaults to latest version.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
- public string Version { get; set; }
- }
-
- /// <summary>
- /// Class CancelPackageInstallation
- /// </summary>
- [Route("/Packages/Installing/{Id}", "DELETE", Summary = "Cancels a package installation")]
- [Authenticated(Roles = "Admin")]
- public class CancelPackageInstallation : IReturnVoid
- {
- /// <summary>
- /// Gets or sets the id.
- /// </summary>
- /// <value>The id.</value>
- [ApiMember(Name = "Id", Description = "Installation Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
- public string Id { get; set; }
- }
-
- /// <summary>
- /// Class PackageService
- /// </summary>
- public class PackageService : BaseApiService
- {
- private readonly IInstallationManager _installationManager;
-
- public PackageService(
- ILogger<PackageService> logger,
- IServerConfigurationManager serverConfigurationManager,
- IHttpResultFactory httpResultFactory,
- IInstallationManager installationManager)
- : base(logger, serverConfigurationManager, httpResultFactory)
- {
- _installationManager = installationManager;
- }
-
- /// <summary>
- /// Gets the specified request.
- /// </summary>
- /// <param name="request">The request.</param>
- /// <returns>System.Object.</returns>
- public object Get(GetPackage request)
- {
- var packages = _installationManager.GetAvailablePackages().GetAwaiter().GetResult();
- var result = _installationManager.FilterPackages(
- packages,
- request.Name,
- string.IsNullOrEmpty(request.AssemblyGuid) ? default : Guid.Parse(request.AssemblyGuid)).FirstOrDefault();
-
- return ToOptimizedResult(result);
- }
-
- /// <summary>
- /// Gets the specified request.
- /// </summary>
- /// <param name="request">The request.</param>
- /// <returns>System.Object.</returns>
- public async Task<object> Get(GetPackages request)
- {
- IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
-
- return ToOptimizedResult(packages.ToArray());
- }
-
- /// <summary>
- /// Posts the specified request.
- /// </summary>
- /// <param name="request">The request.</param>
- /// <exception cref="ResourceNotFoundException"></exception>
- public async Task Post(InstallPackage request)
- {
- var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
- var package = _installationManager.GetCompatibleVersions(
- packages,
- request.Name,
- string.IsNullOrEmpty(request.AssemblyGuid) ? Guid.Empty : Guid.Parse(request.AssemblyGuid),
- string.IsNullOrEmpty(request.Version) ? null : Version.Parse(request.Version)).FirstOrDefault();
-
- if (package == null)
- {
- throw new ResourceNotFoundException(
- string.Format(
- CultureInfo.InvariantCulture,
- "Package not found: {0}",
- request.Name));
- }
-
- await _installationManager.InstallPackage(package);
- }
-
- /// <summary>
- /// Deletes the specified request.
- /// </summary>
- /// <param name="request">The request.</param>
- public void Delete(CancelPackageInstallation request)
- {
- _installationManager.CancelInstallation(new Guid(request.Id));
- }
- }
-}
diff --git a/MediaBrowser.Api/SearchService.cs b/MediaBrowser.Api/SearchService.cs
deleted file mode 100644
index e9d339c6e..000000000
--- a/MediaBrowser.Api/SearchService.cs
+++ /dev/null
@@ -1,333 +0,0 @@
-using System;
-using System.Globalization;
-using System.Linq;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Drawing;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.Audio;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.LiveTv;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Search;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api
-{
- /// <summary>
- /// Class GetSearchHints
- /// </summary>
- [Route("/Search/Hints", "GET", Summary = "Gets search hints based on a search term")]
- public class GetSearchHints : IReturn<SearchHintResult>
- {
- /// <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>
- /// Gets or sets the user id.
- /// </summary>
- /// <value>The user id.</value>
- [ApiMember(Name = "UserId", Description = "Optional. Supply a user id to search within a user's library or omit to search all.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
- public Guid UserId { get; set; }
-
- /// <summary>
- /// Search characters used to find items
- /// </summary>
- /// <value>The index by.</value>
- [ApiMember(Name = "SearchTerm", Description = "The search term to filter on", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
- public string SearchTerm { get; set; }
-
-
- [ApiMember(Name = "IncludePeople", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
- public bool IncludePeople { get; set; }
-
- [ApiMember(Name = "IncludeMedia", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
- public bool IncludeMedia { get; set; }
-
- [ApiMember(Name = "IncludeGenres", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
- public bool IncludeGenres { get; set; }
-
- [ApiMember(Name = "IncludeStudios", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
- public bool IncludeStudios { get; set; }
-
- [ApiMember(Name = "IncludeArtists", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
- public bool IncludeArtists { get; set; }
-
- [ApiMember(Name = "IncludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
- public string IncludeItemTypes { get; set; }
-
- [ApiMember(Name = "ExcludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
- public string ExcludeItemTypes { get; set; }
-
- [ApiMember(Name = "MediaTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)]
- public string MediaTypes { get; set; }
-
- public string ParentId { get; set; }
-
- [ApiMember(Name = "IsMovie", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")]
- public bool? IsMovie { get; set; }
-
- [ApiMember(Name = "IsSeries", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")]
- public bool? IsSeries { get; set; }
-
- [ApiMember(Name = "IsNews", Description = "Optional filter for news.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")]
- public bool? IsNews { get; set; }
-
- [ApiMember(Name = "IsKids", Description = "Optional filter for kids.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")]
- public bool? IsKids { get; set; }
-
- [ApiMember(Name = "IsSports", Description = "Optional filter for sports.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")]
- public bool? IsSports { get; set; }
-
- public GetSearchHints()
- {
- IncludeArtists = true;
- IncludeGenres = true;
- IncludeMedia = true;
- IncludePeople = true;
- IncludeStudios = true;
- }
- }
-
- /// <summary>
- /// Class SearchService
- /// </summary>
- [Authenticated]
- public class SearchService : BaseApiService
- {
- /// <summary>
- /// The _search engine
- /// </summary>
- private readonly ISearchEngine _searchEngine;
- private readonly ILibraryManager _libraryManager;
- private readonly IDtoService _dtoService;
- private readonly IImageProcessor _imageProcessor;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="SearchService" /> class.
- /// </summary>
- /// <param name="searchEngine">The search engine.</param>
- /// <param name="libraryManager">The library manager.</param>
- /// <param name="dtoService">The dto service.</param>
- /// <param name="imageProcessor">The image processor.</param>
- public SearchService(
- ILogger<SearchService> logger,
- IServerConfigurationManager serverConfigurationManager,
- IHttpResultFactory httpResultFactory,
- ISearchEngine searchEngine,
- ILibraryManager libraryManager,
- IDtoService dtoService,
- IImageProcessor imageProcessor)
- : base(logger, serverConfigurationManager, httpResultFactory)
- {
- _searchEngine = searchEngine;
- _libraryManager = libraryManager;
- _dtoService = dtoService;
- _imageProcessor = imageProcessor;
- }
-
- /// <summary>
- /// Gets the specified request.
- /// </summary>
- /// <param name="request">The request.</param>
- /// <returns>System.Object.</returns>
- public object Get(GetSearchHints request)
- {
- var result = GetSearchHintsAsync(request);
-
- return ToOptimizedResult(result);
- }
-
- /// <summary>
- /// Gets the search hints async.
- /// </summary>
- /// <param name="request">The request.</param>
- /// <returns>Task{IEnumerable{SearchHintResult}}.</returns>
- private SearchHintResult GetSearchHintsAsync(GetSearchHints request)
- {
- var result = _searchEngine.GetSearchHints(new SearchQuery
- {
- Limit = request.Limit,
- SearchTerm = request.SearchTerm,
- IncludeArtists = request.IncludeArtists,
- IncludeGenres = request.IncludeGenres,
- IncludeMedia = request.IncludeMedia,
- IncludePeople = request.IncludePeople,
- IncludeStudios = request.IncludeStudios,
- StartIndex = request.StartIndex,
- UserId = request.UserId,
- IncludeItemTypes = ApiEntryPoint.Split(request.IncludeItemTypes, ',', true),
- ExcludeItemTypes = ApiEntryPoint.Split(request.ExcludeItemTypes, ',', true),
- MediaTypes = ApiEntryPoint.Split(request.MediaTypes, ',', true),
- ParentId = request.ParentId,
-
- IsKids = request.IsKids,
- IsMovie = request.IsMovie,
- IsNews = request.IsNews,
- IsSeries = request.IsSeries,
- IsSports = request.IsSports
-
- });
-
- return new SearchHintResult
- {
- TotalRecordCount = result.TotalRecordCount,
-
- SearchHints = result.Items.Select(GetSearchHintResult).ToArray()
- };
- }
-
- /// <summary>
- /// Gets the search hint result.
- /// </summary>
- /// <param name="hintInfo">The hint info.</param>
- /// <returns>SearchHintResult.</returns>
- private SearchHint GetSearchHintResult(SearchHintInfo hintInfo)
- {
- var item = hintInfo.Item;
-
- var result = new SearchHint
- {
- Name = item.Name,
- IndexNumber = item.IndexNumber,
- ParentIndexNumber = item.ParentIndexNumber,
- Id = item.Id,
- Type = item.GetClientTypeName(),
- MediaType = item.MediaType,
- MatchedTerm = hintInfo.MatchedTerm,
- RunTimeTicks = item.RunTimeTicks,
- ProductionYear = item.ProductionYear,
- ChannelId = item.ChannelId,
- EndDate = item.EndDate
- };
-
- // legacy
- result.ItemId = result.Id;
-
- if (item.IsFolder)
- {
- result.IsFolder = true;
- }
-
- var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary);
-
- if (primaryImageTag != null)
- {
- result.PrimaryImageTag = primaryImageTag;
- result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item);
- }
-
- SetThumbImageInfo(result, item);
- SetBackdropImageInfo(result, item);
-
- switch (item)
- {
- case IHasSeries hasSeries:
- result.Series = hasSeries.SeriesName;
- break;
- case LiveTvProgram program:
- result.StartDate = program.StartDate;
- break;
- case Series series:
- if (series.Status.HasValue)
- {
- result.Status = series.Status.Value.ToString();
- }
-
- break;
- case MusicAlbum album:
- result.Artists = album.Artists;
- result.AlbumArtist = album.AlbumArtist;
- break;
- case Audio song:
- result.AlbumArtist = song.AlbumArtists.FirstOrDefault();
- result.Artists = song.Artists;
-
- MusicAlbum musicAlbum = song.AlbumEntity;
-
- if (musicAlbum != null)
- {
- result.Album = musicAlbum.Name;
- result.AlbumId = musicAlbum.Id;
- }
- else
- {
- result.Album = song.Album;
- }
-
- break;
- }
-
- if (!item.ChannelId.Equals(Guid.Empty))
- {
- var channel = _libraryManager.GetItemById(item.ChannelId);
- result.ChannelName = channel?.Name;
- }
-
- return result;
- }
-
- private void SetThumbImageInfo(SearchHint hint, BaseItem item)
- {
- var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null;
-
- if (itemWithImage == null && item is Episode)
- {
- itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb);
- }
-
- if (itemWithImage == null)
- {
- itemWithImage = GetParentWithImage<BaseItem>(item, ImageType.Thumb);
- }
-
- if (itemWithImage != null)
- {
- var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb);
-
- if (tag != null)
- {
- hint.ThumbImageTag = tag;
- hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture);
- }
- }
- }
-
- private void SetBackdropImageInfo(SearchHint hint, BaseItem item)
- {
- var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null)
- ?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop);
-
- if (itemWithImage != null)
- {
- var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop);
-
- if (tag != null)
- {
- hint.BackdropImageTag = tag;
- hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture);
- }
- }
- }
-
- private T GetParentWithImage<T>(BaseItem item, ImageType type)
- where T : BaseItem
- {
- return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type));
- }
- }
-}
diff --git a/MediaBrowser.Api/Sessions/ApiKeyService.cs b/MediaBrowser.Api/Sessions/ApiKeyService.cs
deleted file mode 100644
index 5102ce0a7..000000000
--- a/MediaBrowser.Api/Sessions/ApiKeyService.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-using System;
-using System.Globalization;
-using MediaBrowser.Controller;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Security;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.Sessions
-{
- [Route("/Auth/Keys", "GET")]
- [Authenticated(Roles = "Admin")]
- public class GetKeys
- {
- }
-
- [Route("/Auth/Keys/{Key}", "DELETE")]
- [Authenticated(Roles = "Admin")]
- public class RevokeKey
- {
- [ApiMember(Name = "Key", Description = "Authentication key", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
- public string Key { get; set; }
- }
-
- [Route("/Auth/Keys", "POST")]
- [Authenticated(Roles = "Admin")]
- public class CreateKey
- {
- [ApiMember(Name = "App", Description = "Name of the app using the authentication key", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
- public string App { get; set; }
- }
-
- public class ApiKeyService : BaseApiService
- {
- private readonly ISessionManager _sessionManager;
-
- private readonly IAuthenticationRepository _authRepo;
-
- private readonly IServerApplicationHost _appHost;
-
- public ApiKeyService(
- ILogger<ApiKeyService> logger,
- IServerConfigurationManager serverConfigurationManager,
- IHttpResultFactory httpResultFactory,
- ISessionManager sessionManager,
- IServerApplicationHost appHost,
- IAuthenticationRepository authRepo)
- : base(logger, serverConfigurationManager, httpResultFactory)
- {
- _sessionManager = sessionManager;
- _authRepo = authRepo;
- _appHost = appHost;
- }
-
- public void Delete(RevokeKey request)
- {
- _sessionManager.RevokeToken(request.Key);
- }
-
- public void Post(CreateKey request)
- {
- _authRepo.Create(new AuthenticationInfo
- {
- AppName = request.App,
- AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture),
- DateCreated = DateTime.UtcNow,
- DeviceId = _appHost.SystemId,
- DeviceName = _appHost.FriendlyName,
- AppVersion = _appHost.ApplicationVersionString
- });
- }
-
- public object Get(GetKeys request)
- {
- var result = _authRepo.Get(new AuthenticationInfoQuery
- {
- HasUser = false
- });
-
- return result;
- }
- }
-}
diff --git a/MediaBrowser.Api/Subtitles/SubtitleService.cs b/MediaBrowser.Api/Subtitles/SubtitleService.cs
deleted file mode 100644
index f2968c6b5..000000000
--- a/MediaBrowser.Api/Subtitles/SubtitleService.cs
+++ /dev/null
@@ -1,300 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Controller.Subtitles;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Providers;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-using MimeTypes = MediaBrowser.Model.Net.MimeTypes;
-
-namespace MediaBrowser.Api.Subtitles
-{
- [Route("/Videos/{Id}/Subtitles/{Index}", "DELETE", Summary = "Deletes an external subtitle file")]
- [Authenticated(Roles = "Admin")]
- public class DeleteSubtitle
- {
- /// <summary>
- /// Gets or sets the id.
- /// </summary>
- /// <value>The id.</value>
- [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
- public Guid Id { get; set; }
-
- [ApiMember(Name = "Index", Description = "The subtitle stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "DELETE")]
- public int Index { get; set; }
- }
-
- [Route("/Items/{Id}/RemoteSearch/Subtitles/{Language}", "GET")]
- [Authenticated]
- public class SearchRemoteSubtitles : IReturn<RemoteSubtitleInfo[]>
- {
- [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public Guid Id { get; set; }
-
- [ApiMember(Name = "Language", Description = "Language", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string Language { get; set; }
-
- public bool? IsPerfectMatch { get; set; }
- }
-
- [Route("/Items/{Id}/RemoteSearch/Subtitles/{SubtitleId}", "POST")]
- [Authenticated]
- public class DownloadRemoteSubtitles : IReturnVoid
- {
- [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
- public Guid Id { get; set; }
-
- [ApiMember(Name = "SubtitleId", Description = "SubtitleId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
- public string SubtitleId { get; set; }
- }
-
- [Route("/Providers/Subtitles/Subtitles/{Id}", "GET")]
- [Authenticated]
- public class GetRemoteSubtitles : IReturnVoid
- {
- [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string Id { get; set; }
- }
-
- [Route("/Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}", "GET", Summary = "Gets subtitles in a specified format.")]
- [Route("/Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/{StartPositionTicks}/Stream.{Format}", "GET", Summary = "Gets subtitles in a specified format.")]
- public class GetSubtitle
- {
- /// <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; }
-
- [ApiMember(Name = "MediaSourceId", Description = "MediaSourceId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string MediaSourceId { get; set; }
-
- [ApiMember(Name = "Index", Description = "The subtitle stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")]
- public int Index { get; set; }
-
- [ApiMember(Name = "Format", Description = "Format", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string Format { get; set; }
-
- [ApiMember(Name = "StartPositionTicks", Description = "StartPositionTicks", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
- public long StartPositionTicks { get; set; }
-
- [ApiMember(Name = "EndPositionTicks", Description = "EndPositionTicks", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
- public long? EndPositionTicks { get; set; }
-
- [ApiMember(Name = "CopyTimestamps", Description = "CopyTimestamps", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")]
- public bool CopyTimestamps { get; set; }
- public bool AddVttTimeMap { get; set; }
- }
-
- [Route("/Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/subtitles.m3u8", "GET", Summary = "Gets an HLS subtitle playlist.")]
- [Authenticated]
- public class GetSubtitlePlaylist
- {
- /// <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 = "MediaSourceId", Description = "MediaSourceId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
- public string MediaSourceId { get; set; }
-
- [ApiMember(Name = "Index", Description = "The subtitle stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")]
- public int Index { get; set; }
-
- [ApiMember(Name = "SegmentLength", Description = "The subtitle srgment length", IsRequired = true, DataType = "int", ParameterType = "query", Verb = "GET")]
- public int SegmentLength { get; set; }
- }
-
- public class SubtitleService : BaseApiService
- {
- private readonly ILibraryManager _libraryManager;
- private readonly ISubtitleManager _subtitleManager;
- private readonly ISubtitleEncoder _subtitleEncoder;
- private readonly IMediaSourceManager _mediaSourceManager;
- private readonly IProviderManager _providerManager;
- private readonly IFileSystem _fileSystem;
- private readonly IAuthorizationContext _authContext;
-
- public SubtitleService(
- ILogger<SubtitleService> logger,
- IServerConfigurationManager serverConfigurationManager,
- IHttpResultFactory httpResultFactory,
- ILibraryManager libraryManager,
- ISubtitleManager subtitleManager,
- ISubtitleEncoder subtitleEncoder,
- IMediaSourceManager mediaSourceManager,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- IAuthorizationContext authContext)
- : base(logger, serverConfigurationManager, httpResultFactory)
- {
- _libraryManager = libraryManager;
- _subtitleManager = subtitleManager;
- _subtitleEncoder = subtitleEncoder;
- _mediaSourceManager = mediaSourceManager;
- _providerManager = providerManager;
- _fileSystem = fileSystem;
- _authContext = authContext;
- }
-
- public async Task<object> Get(GetSubtitlePlaylist request)
- {
- var item = (Video)_libraryManager.GetItemById(new Guid(request.Id));
-
- var mediaSource = await _mediaSourceManager.GetMediaSource(item, request.MediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false);
-
- var builder = new StringBuilder();
-
- var runtime = mediaSource.RunTimeTicks ?? -1;
-
- if (runtime <= 0)
- {
- throw new ArgumentException("HLS Subtitles are not supported for this media.");
- }
-
- var segmentLengthTicks = TimeSpan.FromSeconds(request.SegmentLength).Ticks;
- if (segmentLengthTicks <= 0)
- {
- throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)");
- }
-
- builder.AppendLine("#EXTM3U");
- builder.AppendLine("#EXT-X-TARGETDURATION:" + request.SegmentLength.ToString(CultureInfo.InvariantCulture));
- builder.AppendLine("#EXT-X-VERSION:3");
- builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0");
- builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
-
- long positionTicks = 0;
-
- var accessToken = _authContext.GetAuthorizationInfo(Request).Token;
-
- while (positionTicks < runtime)
- {
- var remaining = runtime - positionTicks;
- var lengthTicks = Math.Min(remaining, segmentLengthTicks);
-
- builder.AppendLine("#EXTINF:" + TimeSpan.FromTicks(lengthTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture) + ",");
-
- var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks);
-
- var url = string.Format("stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}",
- positionTicks.ToString(CultureInfo.InvariantCulture),
- endPositionTicks.ToString(CultureInfo.InvariantCulture),
- accessToken);
-
- builder.AppendLine(url);
-
- positionTicks += segmentLengthTicks;
- }
-
- builder.AppendLine("#EXT-X-ENDLIST");
-
- return ResultFactory.GetResult(Request, builder.ToString(), MimeTypes.GetMimeType("playlist.m3u8"), new Dictionary<string, string>());
- }
-
- public async Task<object> Get(GetSubtitle request)
- {
- if (string.Equals(request.Format, "js", StringComparison.OrdinalIgnoreCase))
- {
- request.Format = "json";
- }
- if (string.IsNullOrEmpty(request.Format))
- {
- var item = (Video)_libraryManager.GetItemById(request.Id);
-
- var idString = request.Id.ToString("N", CultureInfo.InvariantCulture);
- var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false, null)
- .First(i => string.Equals(i.Id, request.MediaSourceId ?? idString));
-
- var subtitleStream = mediaSource.MediaStreams
- .First(i => i.Type == MediaStreamType.Subtitle && i.Index == request.Index);
-
- return await ResultFactory.GetStaticFileResult(Request, subtitleStream.Path).ConfigureAwait(false);
- }
-
- if (string.Equals(request.Format, "vtt", StringComparison.OrdinalIgnoreCase) && request.AddVttTimeMap)
- {
- using var stream = await GetSubtitles(request).ConfigureAwait(false);
- using var reader = new StreamReader(stream);
-
- var text = reader.ReadToEnd();
-
- text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000");
-
- return ResultFactory.GetResult(Request, text, MimeTypes.GetMimeType("file." + request.Format));
- }
-
- return ResultFactory.GetResult(Request, await GetSubtitles(request).ConfigureAwait(false), MimeTypes.GetMimeType("file." + request.Format));
- }
-
- private Task<Stream> GetSubtitles(GetSubtitle request)
- {
- var item = _libraryManager.GetItemById(request.Id);
-
- return _subtitleEncoder.GetSubtitles(item,
- request.MediaSourceId,
- request.Index,
- request.Format,
- request.StartPositionTicks,
- request.EndPositionTicks ?? 0,
- request.CopyTimestamps,
- CancellationToken.None);
- }
-
- public async Task<object> Get(SearchRemoteSubtitles request)
- {
- var video = (Video)_libraryManager.GetItemById(request.Id);
-
- return await _subtitleManager.SearchSubtitles(video, request.Language, request.IsPerfectMatch, CancellationToken.None).ConfigureAwait(false);
- }
-
- public Task Delete(DeleteSubtitle request)
- {
- var item = _libraryManager.GetItemById(request.Id);
- return _subtitleManager.DeleteSubtitles(item, request.Index);
- }
-
- public async Task<object> Get(GetRemoteSubtitles request)
- {
- var result = await _subtitleManager.GetRemoteSubtitles(request.Id, CancellationToken.None).ConfigureAwait(false);
-
- return ResultFactory.GetResult(Request, result.Stream, MimeTypes.GetMimeType("file." + result.Format));
- }
-
- public void Post(DownloadRemoteSubtitles request)
- {
- var video = (Video)_libraryManager.GetItemById(request.Id);
-
- Task.Run(async () =>
- {
- try
- {
- await _subtitleManager.DownloadSubtitles(video, request.SubtitleId, CancellationToken.None)
- .ConfigureAwait(false);
-
- _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error downloading subtitles");
- }
- });
- }
- }
-}
diff --git a/MediaBrowser.Api/System/ActivityLogService.cs b/MediaBrowser.Api/System/ActivityLogService.cs
deleted file mode 100644
index a6bacad4f..000000000
--- a/MediaBrowser.Api/System/ActivityLogService.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using System;
-using System.Globalization;
-using System.Linq;
-using Jellyfin.Data.Entities;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.Activity;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Services;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.System
-{
- [Route("/System/ActivityLog/Entries", "GET", Summary = "Gets activity log entries")]
- public class GetActivityLogs : IReturn<QueryResult<ActivityLogEntry>>
- {
- /// <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; }
-
- [ApiMember(Name = "MinDate", Description = "Optional. The minimum date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
- public string MinDate { get; set; }
-
- public bool? HasUserId { get; set; }
- }
-
- [Authenticated(Roles = "Admin")]
- public class ActivityLogService : BaseApiService
- {
- private readonly IActivityManager _activityManager;
-
- public ActivityLogService(
- ILogger<ActivityLogService> logger,
- IServerConfigurationManager serverConfigurationManager,
- IHttpResultFactory httpResultFactory,
- IActivityManager activityManager)
- : base(logger, serverConfigurationManager, httpResultFactory)
- {
- _activityManager = activityManager;
- }
-
- public object Get(GetActivityLogs request)
- {
- DateTime? minDate = string.IsNullOrWhiteSpace(request.MinDate) ?
- (DateTime?)null :
- DateTime.Parse(request.MinDate, null, DateTimeStyles.RoundtripKind).ToUniversalTime();
-
- var filterFunc = new Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>>(
- entries => entries.Where(entry => entry.DateCreated >= minDate));
-
- var result = _activityManager.GetPagedResult(filterFunc, request.StartIndex, request.Limit);
-
- return ToOptimizedResult(result);
- }
- }
-}
diff --git a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs
index 0a36e1cb2..8053461f0 100644
--- a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs
+++ b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs
@@ -25,7 +25,7 @@ namespace MediaBrowser.Common.Json.Converters
/// <param name="typeToConvert">The type to convert.</param>
/// <param name="options">The json serializer options.</param>
/// <returns>Typed dictionary.</returns>
- /// <exception cref="NotSupportedException">Not supported.</exception>
+ /// <exception cref="NotSupportedException">Dictionary key type not supported.</exception>
public override IDictionary<TKey, TValue> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var convertedType = typeof(Dictionary<,>).MakeGenericType(typeof(string), typeToConvert.GenericTypeArguments[1]);
@@ -72,7 +72,7 @@ namespace MediaBrowser.Common.Json.Converters
{
if (k != null)
{
- convertedDictionary[k.ToString()] = v;
+ convertedDictionary[k.ToString()] = v;
}
}
diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs
index 78a458add..adc15123b 100644
--- a/MediaBrowser.Common/Json/JsonDefaults.cs
+++ b/MediaBrowser.Common/Json/JsonDefaults.cs
@@ -12,10 +12,16 @@ namespace MediaBrowser.Common.Json
/// <summary>
/// Gets the default <see cref="JsonSerializerOptions" /> options.
/// </summary>
+ /// <remarks>
+ /// When changing these options, update
+ /// Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+ /// -> AddJellyfinApi
+ /// -> AddJsonOptions.
+ /// </remarks>
/// <returns>The default <see cref="JsonSerializerOptions" /> options.</returns>
public static JsonSerializerOptions GetOptions()
{
- var options = new JsonSerializerOptions()
+ var options = new JsonSerializerOptions
{
ReadCommentHandling = JsonCommentHandling.Disallow,
WriteIndented = false
@@ -27,5 +33,27 @@ namespace MediaBrowser.Common.Json
return options;
}
+
+ /// <summary>
+ /// Gets camelCase json options.
+ /// </summary>
+ /// <returns>The camelCase <see cref="JsonSerializerOptions" /> options.</returns>
+ public static JsonSerializerOptions GetCamelCaseOptions()
+ {
+ var options = GetOptions();
+ options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ return options;
+ }
+
+ /// <summary>
+ /// Gets PascalCase json options.
+ /// </summary>
+ /// <returns>The PascalCase <see cref="JsonSerializerOptions" /> options.</returns>
+ public static JsonSerializerOptions GetPascalCaseOptions()
+ {
+ var options = GetOptions();
+ options.PropertyNamingPolicy = null;
+ return options;
+ }
}
}
diff --git a/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs b/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs
index 29fb81e32..9f2743ea1 100644
--- a/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs
+++ b/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs
@@ -52,6 +52,8 @@ namespace MediaBrowser.Controller.Net
return (Roles ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
}
+ public bool IgnoreLegacyAuth { get; set; }
+
public bool AllowLocalOnly { get; set; }
}
@@ -63,5 +65,7 @@ namespace MediaBrowser.Controller.Net
bool AllowLocalOnly { get; }
string[] GetRoles();
+
+ bool IgnoreLegacyAuth { get; }
}
}
diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs
index 4245c3249..362d41b01 100644
--- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs
+++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs
@@ -90,7 +90,9 @@ namespace Jellyfin.Api.Tests.Auth
var authenticateResult = await _sut.AuthenticateAsync();
Assert.False(authenticateResult.Succeeded);
- Assert.Equal("Invalid user", authenticateResult.Failure.Message);
+ Assert.True(authenticateResult.None);
+ // TODO return when legacy API is removed.
+ // Assert.Equal("Invalid user", authenticateResult.Failure.Message);
}
[Fact]