diff options
27 files changed, 247 insertions, 32 deletions
diff --git a/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs new file mode 100644 index 000000000..3adb700eb --- /dev/null +++ b/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Attributes +{ + /// <summary> + /// Produces file attribute of "image/*". + /// </summary> + public class ProducesAudioFileAttribute : ProducesFileAttribute + { + private const string ContentType = "audio/*"; + + /// <summary> + /// Initializes a new instance of the <see cref="ProducesAudioFileAttribute"/> class. + /// </summary> + public ProducesAudioFileAttribute() + : base(ContentType) + { + } + } +} diff --git a/Jellyfin.Api/Attributes/ProducesFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs new file mode 100644 index 000000000..62a576ede --- /dev/null +++ b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs @@ -0,0 +1,28 @@ +using System; + +namespace Jellyfin.Api.Attributes +{ + /// <summary> + /// Internal produces image attribute. + /// </summary> + [AttributeUsage(AttributeTargets.Method)] + public class ProducesFileAttribute : Attribute + { + private readonly string[] _contentTypes; + + /// <summary> + /// Initializes a new instance of the <see cref="ProducesFileAttribute"/> class. + /// </summary> + /// <param name="contentTypes">Content types this endpoint produces.</param> + public ProducesFileAttribute(params string[] contentTypes) + { + _contentTypes = contentTypes; + } + + /// <summary> + /// Gets the configured content types. + /// </summary> + /// <returns>the configured content types.</returns> + public string[] GetContentTypes() => _contentTypes; + } +} diff --git a/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs new file mode 100644 index 000000000..e15813676 --- /dev/null +++ b/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Attributes +{ + /// <summary> + /// Produces file attribute of "image/*". + /// </summary> + public class ProducesImageFileAttribute : ProducesFileAttribute + { + private const string ContentType = "image/*"; + + /// <summary> + /// Initializes a new instance of the <see cref="ProducesImageFileAttribute"/> class. + /// </summary> + public ProducesImageFileAttribute() + : base(ContentType) + { + } + } +} diff --git a/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs new file mode 100644 index 000000000..5d928ab91 --- /dev/null +++ b/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Attributes +{ + /// <summary> + /// Produces file attribute of "image/*". + /// </summary> + public class ProducesPlaylistFileAttribute : ProducesFileAttribute + { + private const string ContentType = "application/x-mpegURL"; + + /// <summary> + /// Initializes a new instance of the <see cref="ProducesPlaylistFileAttribute"/> class. + /// </summary> + public ProducesPlaylistFileAttribute() + : base(ContentType) + { + } + } +} diff --git a/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs new file mode 100644 index 000000000..d8b2856dc --- /dev/null +++ b/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs @@ -0,0 +1,18 @@ +namespace Jellyfin.Api.Attributes +{ + /// <summary> + /// Produces file attribute of "video/*". + /// </summary> + public class ProducesVideoFileAttribute : ProducesFileAttribute + { + private const string ContentType = "video/*"; + + /// <summary> + /// Initializes a new instance of the <see cref="ProducesVideoFileAttribute"/> class. + /// </summary> + public ProducesVideoFileAttribute() + : base(ContentType) + { + } + } +} diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index 3bec43720..4efd45031 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Controller.MediaEncoding; @@ -89,6 +90,7 @@ namespace Jellyfin.Api.Controllers [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")] [HttpHead("{itemId}/stream", Name = "HeadAudioStream")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] public async Task<ActionResult> GetAudioStream( [FromRoute, Required] Guid itemId, [FromRoute, Required] string? container, diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index 5fd4c712a..8c8c29078 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; +using System.Net.Mime; using System.Text.Json; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Models.ConfigurationDtos; using MediaBrowser.Common.Json; @@ -73,6 +75,7 @@ namespace Jellyfin.Api.Controllers /// <returns>Configuration.</returns> [HttpGet("Configuration/{key}")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile(MediaTypeNames.Application.Json)] public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string? key) { return _configurationManager.GetConfiguration(key); diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index 3f0fc2e91..a859ac114 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Mime; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Models; using MediaBrowser.Common.Plugins; using MediaBrowser.Controller; @@ -106,6 +108,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("web/ConfigurationPage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")] public ActionResult GetDashboardConfigurationPage([FromQuery] string? name) { IPlugin? plugin = null; diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 8cdea4367..271ae293b 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -44,8 +44,9 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="OkResult"/> containing the description xml.</returns> [HttpGet("{serverId}/description")] [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")] - [Produces(MediaTypeNames.Text.Xml)] [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] public ActionResult GetDescriptionXml([FromRoute, Required] string serverId) { var url = GetAbsoluteUri(); @@ -63,8 +64,9 @@ namespace Jellyfin.Api.Controllers [HttpGet("{serverId}/ContentDirectory")] [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")] [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")] - [Produces(MediaTypeNames.Text.Xml)] [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] public ActionResult GetContentDirectory([FromRoute, Required] string serverId) { @@ -79,8 +81,9 @@ namespace Jellyfin.Api.Controllers [HttpGet("{serverId}/MediaReceiverRegistrar")] [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")] [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")] - [Produces(MediaTypeNames.Text.Xml)] [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] public ActionResult GetMediaReceiverRegistrar([FromRoute, Required] string serverId) { @@ -95,8 +98,9 @@ namespace Jellyfin.Api.Controllers [HttpGet("{serverId}/ConnectionManager")] [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")] [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")] - [Produces(MediaTypeNames.Text.Xml)] [ProducesResponseType(StatusCodes.Status200OK)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] public ActionResult GetConnectionManager([FromRoute, Required] string serverId) { @@ -186,6 +190,8 @@ namespace Jellyfin.Api.Controllers /// <returns>Icon stream.</returns> [HttpGet("{serverId}/icons/{fileName}")] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesImageFile] public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName) { return GetIconInternal(fileName); @@ -197,6 +203,7 @@ namespace Jellyfin.Api.Controllers /// <param name="fileName">The icon filename.</param> /// <returns>Icon stream.</returns> [HttpGet("icons/{fileName}")] + [ProducesImageFile] public ActionResult GetIcon([FromRoute, Required] string fileName) { return GetIconInternal(fileName); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index d81c7996e..a0bd68d8c 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.PlaybackDtos; @@ -166,6 +167,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("Videos/{itemId}/master.m3u8")] [HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] public async Task<ActionResult> GetMasterHlsVideoPlaylist( [FromRoute, Required] Guid itemId, [FromRoute, Required] string? container, @@ -333,6 +335,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("Audio/{itemId}/master.m3u8")] [HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] public async Task<ActionResult> GetMasterHlsAudioPlaylist( [FromRoute, Required] Guid itemId, [FromRoute, Required] string? container, @@ -498,6 +501,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("Videos/{itemId}/main.m3u8")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] public async Task<ActionResult> GetVariantHlsVideoPlaylist( [FromRoute, Required] Guid itemId, [FromRoute, Required] string? container, @@ -663,6 +667,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("Audio/{itemId}/main.m3u8")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] public async Task<ActionResult> GetVariantHlsAudioPlaylist( [FromRoute, Required] Guid itemId, [FromRoute, Required] string? container, @@ -830,6 +835,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesVideoFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")] public async Task<ActionResult> GetHlsVideoSegment( [FromRoute, Required] Guid itemId, @@ -999,6 +1005,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")] public async Task<ActionResult> GetHlsAudioSegment( [FromRoute, Required] Guid itemId, diff --git a/Jellyfin.Api/Controllers/EnvironmentController.cs b/Jellyfin.Api/Controllers/EnvironmentController.cs index 64670f7d8..ce88b0b99 100644 --- a/Jellyfin.Api/Controllers/EnvironmentController.cs +++ b/Jellyfin.Api/Controllers/EnvironmentController.cs @@ -69,11 +69,11 @@ namespace Jellyfin.Api.Controllers /// Validates path. /// </summary> /// <param name="validatePathDto">Validate request object.</param> - /// <response code="200">Path validated.</response> + /// <response code="204">Path validated.</response> /// <response code="404">Path not found.</response> /// <returns>Validation status.</returns> [HttpPost("ValidatePath")] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto) { @@ -118,7 +118,7 @@ namespace Jellyfin.Api.Controllers } } - return Ok(); + return NoContent(); } /// <summary> diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index e96df83fa..054e586ce 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using MediaBrowser.Common.Configuration; @@ -55,6 +56,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.mp3", Name = "GetHlsAudioSegmentLegacyMp3")] [HttpGet("Audio/{itemId}/hls/{segmentId}/stream.aac", Name = "GetHlsAudioSegmentLegacyAac")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId) { @@ -75,6 +77,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("Videos/{itemId}/hls/{playlistId}/stream.m3u8")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) { @@ -113,6 +116,7 @@ namespace Jellyfin.Api.Controllers // [Authenticated] [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesVideoFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] public ActionResult GetHlsVideoSegmentLegacy( [FromRoute, Required] string itemId, diff --git a/Jellyfin.Api/Controllers/ImageByNameController.cs b/Jellyfin.Api/Controllers/ImageByNameController.cs index 528590536..f28766760 100644 --- a/Jellyfin.Api/Controllers/ImageByNameController.cs +++ b/Jellyfin.Api/Controllers/ImageByNameController.cs @@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Net.Mime; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; @@ -65,7 +66,8 @@ namespace Jellyfin.Api.Controllers [Produces(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<FileStreamResult> GetGeneralImage([FromRoute, Required] string? name, [FromRoute, Required] string? type) + [ProducesImageFile] + public ActionResult GetGeneralImage([FromRoute, Required] string? name, [FromRoute, Required] string? type) { var filename = string.Equals(type, "primary", StringComparison.OrdinalIgnoreCase) ? "folder" @@ -110,7 +112,8 @@ namespace Jellyfin.Api.Controllers [Produces(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<FileStreamResult> GetRatingImage( + [ProducesImageFile] + public ActionResult GetRatingImage( [FromRoute, Required] string? theme, [FromRoute, Required] string? name) { @@ -143,7 +146,8 @@ namespace Jellyfin.Api.Controllers [Produces(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<FileStreamResult> GetMediaInfoImage( + [ProducesImageFile] + public ActionResult GetMediaInfoImage( [FromRoute, Required] string? theme, [FromRoute, Required] string? name) { @@ -157,7 +161,7 @@ namespace Jellyfin.Api.Controllers /// <param name="theme">Theme to search.</param> /// <param name="name">File name to search for.</param> /// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns> - private ActionResult<FileStreamResult> GetImageFile(string basePath, string? theme, string? name) + private ActionResult GetImageFile(string basePath, string? theme, string? name) { var themeFolder = Path.Combine(basePath, theme); if (Directory.Exists(themeFolder)) @@ -168,7 +172,7 @@ namespace Jellyfin.Api.Controllers if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) { var contentType = MimeTypes.GetMimeType(path); - return File(System.IO.File.OpenRead(path), contentType); + return PhysicalFile(path, contentType); } } @@ -181,7 +185,7 @@ namespace Jellyfin.Api.Controllers if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path)) { var contentType = MimeTypes.GetMimeType(path); - return File(System.IO.File.OpenRead(path), contentType); + return PhysicalFile(path, contentType); } } diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 453da5711..3006918ca 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using MediaBrowser.Controller.Configuration; @@ -352,6 +353,7 @@ namespace Jellyfin.Api.Controllers [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] public async Task<ActionResult> GetItemImage( [FromRoute, Required] Guid itemId, [FromRoute, Required] ImageType imageType, @@ -430,6 +432,7 @@ namespace Jellyfin.Api.Controllers [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] public async Task<ActionResult> GetItemImage2( [FromRoute, Required] Guid itemId, [FromRoute, Required] ImageType imageType, @@ -508,6 +511,7 @@ namespace Jellyfin.Api.Controllers [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadArtistImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] public async Task<ActionResult> GetArtistImage( [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, @@ -586,6 +590,7 @@ namespace Jellyfin.Api.Controllers [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] public async Task<ActionResult> GetGenreImage( [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, @@ -664,6 +669,7 @@ namespace Jellyfin.Api.Controllers [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] public async Task<ActionResult> GetMusicGenreImage( [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, @@ -742,6 +748,7 @@ namespace Jellyfin.Api.Controllers [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] public async Task<ActionResult> GetPersonImage( [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, @@ -820,6 +827,7 @@ namespace Jellyfin.Api.Controllers [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] public async Task<ActionResult> GetStudioImage( [FromRoute, Required] string name, [FromRoute, Required] ImageType imageType, @@ -898,6 +906,7 @@ namespace Jellyfin.Api.Controllers [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] public async Task<ActionResult> GetUserImage( [FromRoute, Required] Guid userId, [FromRoute, Required] ImageType imageType, @@ -1298,8 +1307,7 @@ namespace Jellyfin.Api.Controllers return NoContent(); } - var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read); - return File(stream, imageContentType); + return PhysicalFile(imagePath, imageContentType); } } } diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index f7b515cec..cf7038650 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -7,6 +7,7 @@ using System.Net.Mime; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; @@ -18,6 +19,7 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; using MediaBrowser.Model.Providers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -248,6 +250,8 @@ namespace Jellyfin.Api.Controllers /// The task result contains an <see cref="FileStreamResult"/> containing the images file stream. /// </returns> [HttpGet("Items/RemoteSearch/Image")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesImageFile] public async Task<ActionResult> GetRemoteSearchImage( [FromQuery, Required] string imageUrl, [FromQuery, Required] string providerName) @@ -260,8 +264,7 @@ namespace Jellyfin.Api.Controllers var contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); if (System.IO.File.Exists(contentPath)) { - await using var fileStreamExisting = System.IO.File.OpenRead(pointerCachePath); - return new FileStreamResult(fileStreamExisting, MediaTypeNames.Application.Octet); + return PhysicalFile(contentPath, MimeTypes.GetMimeType(contentPath)); } } catch (FileNotFoundException) @@ -274,10 +277,8 @@ namespace Jellyfin.Api.Controllers } await DownloadImage(providerName, imageUrl, urlHash, pointerCachePath).ConfigureAwait(false); - - // Read the pointer file again - await using var fileStream = System.IO.File.OpenRead(pointerCachePath); - return new FileStreamResult(fileStream, MediaTypeNames.Application.Octet); + var updatedContentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false); + return PhysicalFile(updatedContentPath, MimeTypes.GetMimeType(updatedContentPath)); } /// <summary> @@ -293,6 +294,7 @@ namespace Jellyfin.Api.Controllers /// </returns> [HttpPost("Items/RemoteSearch/Apply/{id}")] [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> ApplySearchCriteria( [FromRoute, Required] Guid itemId, [FromBody, Required] RemoteSearchResult searchResult, diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index f1f52961d..78d70b04e 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -8,6 +8,7 @@ using System.Net; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; @@ -104,6 +105,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesFile("video/*", "audio/*")] public ActionResult GetFile([FromRoute, Required] Guid itemId) { var item = _libraryManager.GetItemById(itemId); @@ -112,8 +114,7 @@ namespace Jellyfin.Api.Controllers return NotFound(); } - using var fileStream = new FileStream(item.Path, FileMode.Open, FileAccess.Read); - return File(fileStream, MimeTypes.GetMimeType(item.Path)); + return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path)); } /// <summary> @@ -618,6 +619,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.Download)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesFile("video/*", "audio/*")] public async Task<ActionResult> GetDownload([FromRoute, Required] Guid itemId) { var item = _libraryManager.GetItemById(itemId); diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 500907a9a..6b1fde438 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -10,6 +10,7 @@ using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; @@ -1069,6 +1070,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("ListingProviders/SchedulesDirect/Countries")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile(MediaTypeNames.Application.Json)] public async Task<ActionResult> GetSchedulesDirectCountries() { var client = _httpClientFactory.CreateClient(NamedClient.Default); @@ -1177,6 +1179,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("LiveRecordings/{recordingId}/stream")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesVideoFile] public async Task<ActionResult> GetLiveRecordingFile([FromRoute, Required] string recordingId) { var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); @@ -1207,6 +1210,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("LiveStreamFiles/{streamId}/stream.{container}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesVideoFile] public async Task<ActionResult> GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container) { var liveStreamInfo = await _mediaSourceManager.GetDirectStreamProviderByUniqueId(streamId, CancellationToken.None).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index cc6eba4ae..ee171e856 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net.Mime; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.MediaInfoDtos; @@ -286,6 +287,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [Produces(MediaTypeNames.Application.Octet)] + [ProducesFile(MediaTypeNames.Application.Octet)] public ActionResult GetBitrateTestBytes([FromQuery] int size = 102400) { const int MaxSize = 10_000_000; diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index bdc817126..5f095443b 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -7,6 +7,7 @@ using System.Net.Http; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -155,6 +156,7 @@ namespace Jellyfin.Api.Controllers [Produces(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] public async Task<ActionResult> GetRemoteImage([FromQuery, Required] string imageUrl) { var urlHash = imageUrl.GetMD5(); @@ -192,7 +194,7 @@ namespace Jellyfin.Api.Controllers } var contentType = MimeTypes.GetMimeType(contentPath); - return File(System.IO.File.OpenRead(contentPath), contentType); + return PhysicalFile(contentPath, contentType); } /// <summary> diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index 2c82b5423..ff26ca921 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -9,6 +9,7 @@ using System.Net.Mime; using System.Text; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -162,6 +163,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [Produces(MediaTypeNames.Application.Octet)] + [ProducesFile("text/*")] public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string? id) { var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false); @@ -185,6 +187,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")] [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("text/*")] public async Task<ActionResult> GetSubtitle( [FromRoute, Required] Guid itemId, [FromRoute, Required] string? mediaSourceId, @@ -211,8 +214,7 @@ namespace Jellyfin.Api.Controllers 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)); + return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); } if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) @@ -251,6 +253,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] public async Task<ActionResult> GetSubtitlePlaylist( [FromRoute, Required] Guid itemId, diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index bbfd163de..e878e8f36 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; @@ -190,6 +192,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("Logs/Log")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile(MediaTypeNames.Text.Plain)] public ActionResult GetLogFile([FromQuery, Required] string? name) { var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) @@ -197,7 +200,6 @@ namespace Jellyfin.Api.Controllers // For older files, assume fully static var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite; - FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare); return File(stream, "text/plain"); } diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index f7f2d0174..6bbdbc613 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.StreamingDtos; @@ -92,6 +93,7 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesAudioFile] public async Task<ActionResult> GetUniversalAudioStream( [FromRoute, Required] Guid itemId, [FromRoute, Required] string? container, diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 95067bc17..435588146 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -125,7 +125,7 @@ namespace Jellyfin.Api.Controllers /// Deletes a user. /// </summary> /// <param name="userId">The user id.</param> - /// <response code="200">User deleted.</response> + /// <response code="204">User deleted.</response> /// <response code="404">User not found.</response> /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns> [HttpDelete("{userId}")] @@ -255,7 +255,7 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="userId">The user id.</param> /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param> - /// <response code="200">Password successfully reset.</response> + /// <response code="204">Password successfully reset.</response> /// <response code="403">User is not allowed to update the password.</response> /// <response code="404">User not found.</response> /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> @@ -313,7 +313,7 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="userId">The user id.</param> /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param> - /// <response code="200">Password successfully reset.</response> + /// <response code="204">Password successfully reset.</response> /// <response code="403">User is not allowed to update the password.</response> /// <response code="404">User not found.</response> /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns> diff --git a/Jellyfin.Api/Controllers/VideoHlsController.cs b/Jellyfin.Api/Controllers/VideoHlsController.cs index dabf04dee..2afa878f4 100644 --- a/Jellyfin.Api/Controllers/VideoHlsController.cs +++ b/Jellyfin.Api/Controllers/VideoHlsController.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.IO; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.PlaybackDtos; @@ -162,6 +163,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="FileResult"/> containing the hls file.</returns> [HttpGet("Videos/{itemId}/live.m3u8")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesPlaylistFile] public async Task<ActionResult> GetLiveHlsStream( [FromRoute, Required] Guid itemId, [FromQuery] string? container, diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 5c65399cb..c07ee5a9c 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; @@ -160,7 +161,7 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="NotFoundResult"/> if the video doesn't exist.</returns> [HttpDelete("{itemId}/AlternateSources")] [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId) { @@ -330,6 +331,7 @@ namespace Jellyfin.Api.Controllers [HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStream_2")] [HttpHead("{itemId}/stream", Name = "HeadVideoStream")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesVideoFile] public async Task<ActionResult> GetVideoStream( [FromRoute, Required] Guid itemId, [FromRoute, Required] string? container, diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 9319b573a..71148701b 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -16,8 +16,8 @@ using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; using Jellyfin.Server.Configuration; +using Jellyfin.Server.Filters; using Jellyfin.Server.Formatters; -using Jellyfin.Server.Middleware; using MediaBrowser.Common.Json; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authentication; @@ -248,6 +248,8 @@ namespace Jellyfin.Server.Extensions // TODO - remove when all types are supported in System.Text.Json c.AddSwaggerTypeMappings(); + + c.OperationFilter<FileResponseFilter>(); }); } diff --git a/Jellyfin.Server/Filters/FileResponseFilter.cs b/Jellyfin.Server/Filters/FileResponseFilter.cs new file mode 100644 index 000000000..8ea35c281 --- /dev/null +++ b/Jellyfin.Server/Filters/FileResponseFilter.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using Jellyfin.Api.Attributes; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Jellyfin.Server.Filters +{ + /// <inheritdoc /> + public class FileResponseFilter : IOperationFilter + { + private const string SuccessCode = "200"; + private static readonly OpenApiMediaType _openApiMediaType = new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Type = "file" + } + }; + + /// <inheritdoc /> + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + foreach (var attribute in context.ApiDescription.ActionDescriptor.EndpointMetadata) + { + if (attribute is ProducesFileAttribute producesFileAttribute) + { + // Get operation response values. + var (_, value) = operation.Responses + .FirstOrDefault(o => o.Key.Equals(SuccessCode, StringComparison.Ordinal)); + + // Operation doesn't have a response. + if (value == null) + { + continue; + } + + // Clear existing responses. + value.Content.Clear(); + + // Add all content-types as file. + foreach (var contentType in producesFileAttribute.GetContentTypes()) + { + value.Content.Add(contentType, _openApiMediaType); + } + + break; + } + } + } + } +} |
