Bladeren bron

Merge remote-tracking branch 'upstream/master' into bad-route

crobibero 4 jaren geleden
bovenliggende
commit
efce4d4bf3
27 gewijzigde bestanden met toevoegingen van 247 en 32 verwijderingen
  1. 18 0
      Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs
  2. 28 0
      Jellyfin.Api/Attributes/ProducesFileAttribute.cs
  3. 18 0
      Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs
  4. 18 0
      Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs
  5. 18 0
      Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs
  6. 2 0
      Jellyfin.Api/Controllers/AudioController.cs
  7. 3 0
      Jellyfin.Api/Controllers/ConfigurationController.cs
  8. 3 0
      Jellyfin.Api/Controllers/DashboardController.cs
  9. 11 4
      Jellyfin.Api/Controllers/DlnaServerController.cs
  10. 7 0
      Jellyfin.Api/Controllers/DynamicHlsController.cs
  11. 3 3
      Jellyfin.Api/Controllers/EnvironmentController.cs
  12. 4 0
      Jellyfin.Api/Controllers/HlsSegmentController.cs
  13. 10 6
      Jellyfin.Api/Controllers/ImageByNameController.cs
  14. 10 2
      Jellyfin.Api/Controllers/ImageController.cs
  15. 8 6
      Jellyfin.Api/Controllers/ItemLookupController.cs
  16. 4 2
      Jellyfin.Api/Controllers/LibraryController.cs
  17. 4 0
      Jellyfin.Api/Controllers/LiveTvController.cs
  18. 2 0
      Jellyfin.Api/Controllers/MediaInfoController.cs
  19. 3 1
      Jellyfin.Api/Controllers/RemoteImageController.cs
  20. 5 2
      Jellyfin.Api/Controllers/SubtitleController.cs
  21. 3 1
      Jellyfin.Api/Controllers/SystemController.cs
  22. 2 0
      Jellyfin.Api/Controllers/UniversalAudioController.cs
  23. 3 3
      Jellyfin.Api/Controllers/UserController.cs
  24. 2 0
      Jellyfin.Api/Controllers/VideoHlsController.cs
  25. 3 1
      Jellyfin.Api/Controllers/VideosController.cs
  26. 3 1
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  27. 52 0
      Jellyfin.Server/Filters/FileResponseFilter.cs

+ 18 - 0
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)
+        {
+        }
+    }
+}

+ 28 - 0
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;
+    }
+}

+ 18 - 0
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)
+        {
+        }
+    }
+}

+ 18 - 0
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)
+        {
+        }
+    }
+}

+ 18 - 0
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)
+        {
+        }
+    }
+}

+ 2 - 0
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:required}", Name = "HeadAudioStreamByContainer")]
         [HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
         [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesAudioFile]
         public async Task<ActionResult> GetAudioStream(
             [FromRoute, Required] Guid itemId,
             [FromRoute] string? container,

+ 3 - 0
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);

+ 3 - 0
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;

+ 11 - 4
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);

+ 7 - 0
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,
             [FromQuery, 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,
             [FromQuery, 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,
             [FromQuery, 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,

+ 3 - 3
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>

+ 4 - 0
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,

+ 10 - 6
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);
                 }
             }
 

+ 10 - 2
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);
         }
     }
 }

+ 8 - 6
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,

+ 4 - 2
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);

+ 4 - 0
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);

+ 2 - 0
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;

+ 3 - 1
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>

+ 5 - 2
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,

+ 3 - 1
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");
         }

+ 2 - 0
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] string? container,

+ 3 - 3
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>

+ 2 - 0
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,

+ 3 - 1
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] string? container,

+ 3 - 1
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>();
             });
         }
 

+ 52 - 0
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;
+                }
+            }
+        }
+    }
+}