aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Api
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Api')
-rw-r--r--Jellyfin.Api/Auth/CustomAuthenticationHandler.cs16
-rw-r--r--Jellyfin.Api/BaseJellyfinApiController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ActivityLogController.cs57
-rw-r--r--Jellyfin.Api/Controllers/ApiKeyController.cs97
-rw-r--r--Jellyfin.Api/Controllers/ConfigurationController.cs2
-rw-r--r--Jellyfin.Api/Controllers/DevicesController.cs156
-rw-r--r--Jellyfin.Api/Controllers/PackageController.cs120
-rw-r--r--Jellyfin.Api/Controllers/SearchController.cs268
-rw-r--r--Jellyfin.Api/Controllers/StartupController.cs1
-rw-r--r--Jellyfin.Api/Controllers/VideoAttachmentsController.cs84
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs23
-rw-r--r--Jellyfin.Api/Jellyfin.Api.csproj8
-rw-r--r--Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs6
-rw-r--r--Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs2
-rw-r--r--Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs2
15 files changed, 836 insertions, 8 deletions
diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
index 26f7d9d2d..100054096 100644
--- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
+++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
@@ -1,3 +1,4 @@
+using System.Security.Authentication;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
@@ -37,13 +38,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[]
@@ -59,6 +67,10 @@ namespace Jellyfin.Api.Auth
return Task.FromResult(AuthenticateResult.Success(ticket));
}
+ catch (AuthenticationException ex)
+ {
+ return Task.FromResult(AuthenticateResult.Fail(ex));
+ }
catch (SecurityException ex)
{
return Task.FromResult(AuthenticateResult.Fail(ex));
diff --git a/Jellyfin.Api/BaseJellyfinApiController.cs b/Jellyfin.Api/BaseJellyfinApiController.cs
index 1f4508e6c..a34f9eb62 100644
--- a/Jellyfin.Api/BaseJellyfinApiController.cs
+++ b/Jellyfin.Api/BaseJellyfinApiController.cs
@@ -1,3 +1,4 @@
+using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Api
@@ -7,6 +8,7 @@ namespace Jellyfin.Api
/// </summary>
[ApiController]
[Route("[controller]")]
+ [Produces(MediaTypeNames.Application.Json)]
public class BaseJellyfinApiController : ControllerBase
{
}
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
index 8243bfce4..2a1dce74d 100644
--- a/Jellyfin.Api/Controllers/ConfigurationController.cs
+++ b/Jellyfin.Api/Controllers/ConfigurationController.cs
@@ -89,7 +89,7 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string key)
{
var configurationType = _configurationManager.GetConfigurationType(key);
- var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType);
+ var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType).ConfigureAwait(false);
_configurationManager.SaveConfiguration(key, configuration);
return Ok();
}
diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs
new file mode 100644
index 000000000..1e7557903
--- /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="200">Device options updated.</response>
+ /// <response code="404">Device not found.</response>
+ /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+ [HttpPost("Options")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [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 Ok();
+ }
+
+ /// <summary>
+ /// Deletes a device.
+ /// </summary>
+ /// <param name="id">Device Id.</param>
+ /// <response code="200">Device deleted.</response>
+ /// <response code="404">Device not found.</response>
+ /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+ [HttpDelete]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ 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 Ok();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs
new file mode 100644
index 000000000..f37319c19
--- /dev/null
+++ b/Jellyfin.Api/Controllers/PackageController.cs
@@ -0,0 +1,120 @@
+#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="200">Package found.</response>
+ /// <response code="404">Package not found.</response>
+ /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns>
+ [HttpPost("/Installed/{Name}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [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 Ok();
+ }
+
+ /// <summary>
+ /// Cancels a package installation.
+ /// </summary>
+ /// <param name="id">Installation Id.</param>
+ /// <response code="200">Installation cancelled.</response>
+ /// <returns>An <see cref="OkResult"/> on successfully cancelling a package installation.</returns>
+ [HttpDelete("/Installing/{id}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ public IActionResult CancelPackageInstallation(
+ [FromRoute] [Required] string id)
+ {
+ _installationManager.CancelInstallation(new Guid(id));
+
+ return Ok();
+ }
+ }
+}
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 ed1dc1ede..57a02e62a 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -109,6 +109,7 @@ namespace Jellyfin.Api.Controllers
/// <response code="200">Initial user retrieved.</response>
/// <returns>The first user.</returns>
[HttpGet("User")]
+ [HttpGet("FirstUser")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<StartupUserDto> GetFirstUser()
{
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
index e74555f33..1e9aa7b43 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Model.Entities;
@@ -71,7 +71,7 @@ namespace Jellyfin.Api.Helpers
}
return null;
- }).Where(i => i.HasValue).Select(i => i.Value).ToArray();
+ }).Where(i => i.HasValue).Select(i => i!.Value).ToArray();
}
/// <summary>
@@ -85,5 +85,24 @@ namespace Jellyfin.Api.Helpers
? Array.Empty<ItemFilter>()
: filters.Split(',').Select(v => Enum.Parse<ItemFilter>(v, true));
}
+
+ /// <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 77bb52c6a..57af29a92 100644
--- a/Jellyfin.Api/Jellyfin.Api.csproj
+++ b/Jellyfin.Api/Jellyfin.Api.csproj
@@ -1,14 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
+ <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
+ <PropertyGroup>
+ <ProjectGuid>{DFBEFB4C-DA19-4143-98B7-27320C7F7163}</ProjectGuid>
+ </PropertyGroup>
+
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ <Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.2.0" />
- <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="3.1.3" />
+ <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="3.1.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.3.3" />
<PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="5.3.3" />
diff --git a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
index b05e0cdf5..3706a11e3 100644
--- a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
+++ b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
@@ -1,3 +1,5 @@
+#nullable enable
+
namespace Jellyfin.Api.Models.ConfigurationDtos
{
/// <summary>
@@ -8,11 +10,11 @@ namespace Jellyfin.Api.Models.ConfigurationDtos
/// <summary>
/// Gets or sets media encoder path.
/// </summary>
- public string Path { get; set; }
+ public string Path { get; set; } = null!;
/// <summary>
/// Gets or sets media encoder path type.
/// </summary>
- public string PathType { get; set; }
+ public string PathType { get; set; } = null!;
}
}
diff --git a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs
index d048dad0a..5a83a030d 100644
--- a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs
+++ b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs
@@ -1,3 +1,5 @@
+#nullable disable
+
namespace Jellyfin.Api.Models.StartupDtos
{
/// <summary>
diff --git a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs
index 3a9348037..0dbb245ec 100644
--- a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs
+++ b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs
@@ -1,3 +1,5 @@
+#nullable disable
+
namespace Jellyfin.Api.Models.StartupDtos
{
/// <summary>