diff options
Diffstat (limited to 'Jellyfin.Api')
| -rw-r--r-- | Jellyfin.Api/Auth/CustomAuthenticationHandler.cs | 5 | ||||
| -rw-r--r-- | Jellyfin.Api/BaseJellyfinApiController.cs | 2 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/ActivityLogController.cs | 57 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/ApiKeyController.cs | 97 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/PackageController.cs | 120 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/SearchController.cs | 268 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/StartupController.cs | 1 | ||||
| -rw-r--r-- | Jellyfin.Api/Controllers/VideoAttachmentsController.cs | 84 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/RequestHelpers.cs | 29 | ||||
| -rw-r--r-- | Jellyfin.Api/Jellyfin.Api.csproj | 8 | ||||
| -rw-r--r-- | Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs | 6 | ||||
| -rw-r--r-- | Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs | 2 | ||||
| -rw-r--r-- | Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs | 2 |
13 files changed, 678 insertions, 3 deletions
diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index a0c9c3f5a..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; @@ -66,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/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 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 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> |
