diff options
| author | Cody Robibero <cody@robibe.ro> | 2023-02-04 10:21:49 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-02-04 10:21:49 -0700 |
| commit | d1af317d98a6190711af406af17b569844aebbd7 (patch) | |
| tree | 3422bb577d2821a9798465439e983932690aa2e3 | |
| parent | e0519189b25bc4605889e46d9583fea9aef41732 (diff) | |
| parent | dfea1229e12764a77f5d392194b1848f80b87042 (diff) | |
Merge pull request #9215 from Shadowghost/api-scoped-namespace
163 files changed, 22701 insertions, 22876 deletions
diff --git a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs index fbe68b6b9..a6c89bab8 100644 --- a/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs +++ b/Jellyfin.Api/Attributes/AcceptsFileAttribute.cs @@ -2,29 +2,28 @@ using System; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Internal produces image attribute. +/// </summary> +[AttributeUsage(AttributeTargets.Method)] +public class AcceptsFileAttribute : Attribute { + private readonly string[] _contentTypes; + /// <summary> - /// Internal produces image attribute. + /// Initializes a new instance of the <see cref="AcceptsFileAttribute"/> class. /// </summary> - [AttributeUsage(AttributeTargets.Method)] - public class AcceptsFileAttribute : Attribute + /// <param name="contentTypes">Content types this endpoint produces.</param> + public AcceptsFileAttribute(params string[] contentTypes) { - private readonly string[] _contentTypes; - - /// <summary> - /// Initializes a new instance of the <see cref="AcceptsFileAttribute"/> class. - /// </summary> - /// <param name="contentTypes">Content types this endpoint produces.</param> - public AcceptsFileAttribute(params string[] contentTypes) - { - _contentTypes = contentTypes; - } - - /// <summary> - /// Gets the configured content types. - /// </summary> - /// <returns>the configured content types.</returns> - public string[] ContentTypes => _contentTypes; + _contentTypes = contentTypes; } + + /// <summary> + /// Gets the configured content types. + /// </summary> + /// <returns>the configured content types.</returns> + public string[] ContentTypes => _contentTypes; } diff --git a/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs b/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs index 244a29da4..57433202e 100644 --- a/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs +++ b/Jellyfin.Api/Attributes/AcceptsImageFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Produces file attribute of "image/*". +/// </summary> +public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute { + private const string ContentType = "image/*"; + /// <summary> - /// Produces file attribute of "image/*". + /// Initializes a new instance of the <see cref="AcceptsImageFileAttribute"/> class. /// </summary> - public sealed class AcceptsImageFileAttribute : AcceptsFileAttribute + public AcceptsImageFileAttribute() + : base(ContentType) { - private const string ContentType = "image/*"; - - /// <summary> - /// Initializes a new instance of the <see cref="AcceptsImageFileAttribute"/> class. - /// </summary> - public AcceptsImageFileAttribute() - : base(ContentType) - { - } } } diff --git a/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs b/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs index 4dcf5976a..cbd32ed82 100644 --- a/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs +++ b/Jellyfin.Api/Attributes/HttpSubscribeAttribute.cs @@ -2,29 +2,28 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.Routing; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Identifies an action that supports the HTTP GET method. +/// </summary> +public sealed class HttpSubscribeAttribute : HttpMethodAttribute { + private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" }; + /// <summary> - /// Identifies an action that supports the HTTP GET method. + /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class. /// </summary> - public sealed class HttpSubscribeAttribute : HttpMethodAttribute + public HttpSubscribeAttribute() + : base(_supportedMethods) { - private static readonly IEnumerable<string> _supportedMethods = new[] { "SUBSCRIBE" }; - - /// <summary> - /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class. - /// </summary> - public HttpSubscribeAttribute() - : base(_supportedMethods) - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class. - /// </summary> - /// <param name="template">The route template. May not be null.</param> - public HttpSubscribeAttribute(string template) - : base(_supportedMethods, template) - => ArgumentNullException.ThrowIfNull(template); } + + /// <summary> + /// Initializes a new instance of the <see cref="HttpSubscribeAttribute"/> class. + /// </summary> + /// <param name="template">The route template. May not be null.</param> + public HttpSubscribeAttribute(string template) + : base(_supportedMethods, template) + => ArgumentNullException.ThrowIfNull(template); } diff --git a/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs b/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs index d0238424a..f4a6dcdaf 100644 --- a/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs +++ b/Jellyfin.Api/Attributes/HttpUnsubscribeAttribute.cs @@ -2,29 +2,28 @@ using System; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc.Routing; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Identifies an action that supports the HTTP GET method. +/// </summary> +public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute { + private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" }; + /// <summary> - /// Identifies an action that supports the HTTP GET method. + /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class. /// </summary> - public sealed class HttpUnsubscribeAttribute : HttpMethodAttribute + public HttpUnsubscribeAttribute() + : base(_supportedMethods) { - private static readonly IEnumerable<string> _supportedMethods = new[] { "UNSUBSCRIBE" }; - - /// <summary> - /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class. - /// </summary> - public HttpUnsubscribeAttribute() - : base(_supportedMethods) - { - } - - /// <summary> - /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class. - /// </summary> - /// <param name="template">The route template. May not be null.</param> - public HttpUnsubscribeAttribute(string template) - : base(_supportedMethods, template) - => ArgumentNullException.ThrowIfNull(template); } + + /// <summary> + /// Initializes a new instance of the <see cref="HttpUnsubscribeAttribute"/> class. + /// </summary> + /// <param name="template">The route template. May not be null.</param> + public HttpUnsubscribeAttribute(string template) + : base(_supportedMethods, template) + => ArgumentNullException.ThrowIfNull(template); } diff --git a/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs b/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs index 514e7ce97..bf64fef5d 100644 --- a/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs +++ b/Jellyfin.Api/Attributes/ParameterObsoleteAttribute.cs @@ -1,12 +1,11 @@ using System; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Attribute to mark a parameter as obsolete. +/// </summary> +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class ParameterObsoleteAttribute : Attribute { - /// <summary> - /// Attribute to mark a parameter as obsolete. - /// </summary> - [AttributeUsage(AttributeTargets.Parameter)] - public sealed class ParameterObsoleteAttribute : Attribute - { - } } diff --git a/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs index 9fc25f192..7ce09c299 100644 --- a/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesAudioFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Produces file attribute of "image/*". +/// </summary> +public sealed class ProducesAudioFileAttribute : ProducesFileAttribute { + private const string ContentType = "audio/*"; + /// <summary> - /// Produces file attribute of "image/*". + /// Initializes a new instance of the <see cref="ProducesAudioFileAttribute"/> class. /// </summary> - public sealed class ProducesAudioFileAttribute : ProducesFileAttribute + public ProducesAudioFileAttribute() + : base(ContentType) { - 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 index d8e4141ac..c728f68e0 100644 --- a/Jellyfin.Api/Attributes/ProducesFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesFileAttribute.cs @@ -2,29 +2,28 @@ using System; -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Internal produces image attribute. +/// </summary> +[AttributeUsage(AttributeTargets.Method)] +public class ProducesFileAttribute : Attribute { + private readonly string[] _contentTypes; + /// <summary> - /// Internal produces image attribute. + /// Initializes a new instance of the <see cref="ProducesFileAttribute"/> class. /// </summary> - [AttributeUsage(AttributeTargets.Method)] - public class ProducesFileAttribute : Attribute + /// <param name="contentTypes">Content types this endpoint produces.</param> + public ProducesFileAttribute(params string[] contentTypes) { - 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[] ContentTypes => _contentTypes; + _contentTypes = contentTypes; } + + /// <summary> + /// Gets the configured content types. + /// </summary> + /// <returns>the configured content types.</returns> + public string[] ContentTypes => _contentTypes; } diff --git a/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs b/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs index 1e5b542e2..f145a061e 100644 --- a/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesImageFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Produces file attribute of "image/*". +/// </summary> +public sealed class ProducesImageFileAttribute : ProducesFileAttribute { + private const string ContentType = "image/*"; + /// <summary> - /// Produces file attribute of "image/*". + /// Initializes a new instance of the <see cref="ProducesImageFileAttribute"/> class. /// </summary> - public sealed class ProducesImageFileAttribute : ProducesFileAttribute + public ProducesImageFileAttribute() + : base(ContentType) { - 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 index 5b15cb1a5..c03ed740c 100644 --- a/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesPlaylistFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Produces file attribute of "image/*". +/// </summary> +public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute { + private const string ContentType = "application/x-mpegURL"; + /// <summary> - /// Produces file attribute of "image/*". + /// Initializes a new instance of the <see cref="ProducesPlaylistFileAttribute"/> class. /// </summary> - public sealed class ProducesPlaylistFileAttribute : ProducesFileAttribute + public ProducesPlaylistFileAttribute() + : base(ContentType) { - 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 index 6857d45ec..10dec0c00 100644 --- a/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs +++ b/Jellyfin.Api/Attributes/ProducesVideoFileAttribute.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Attributes +namespace Jellyfin.Api.Attributes; + +/// <summary> +/// Produces file attribute of "video/*". +/// </summary> +public sealed class ProducesVideoFileAttribute : ProducesFileAttribute { + private const string ContentType = "video/*"; + /// <summary> - /// Produces file attribute of "video/*". + /// Initializes a new instance of the <see cref="ProducesVideoFileAttribute"/> class. /// </summary> - public sealed class ProducesVideoFileAttribute : ProducesFileAttribute + public ProducesVideoFileAttribute() + : base(ContentType) { - 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/BaseJellyfinApiController.cs b/Jellyfin.Api/BaseJellyfinApiController.cs index e327831fe..5b4bd0adb 100644 --- a/Jellyfin.Api/BaseJellyfinApiController.cs +++ b/Jellyfin.Api/BaseJellyfinApiController.cs @@ -4,35 +4,34 @@ using Jellyfin.Api.Results; using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api +namespace Jellyfin.Api; + +/// <summary> +/// Base api controller for the API setting a default route. +/// </summary> +[ApiController] +[Route("[controller]")] +[Produces( + MediaTypeNames.Application.Json, + JsonDefaults.CamelCaseMediaType, + JsonDefaults.PascalCaseMediaType)] +public class BaseJellyfinApiController : ControllerBase { /// <summary> - /// Base api controller for the API setting a default route. + /// Create a new <see cref="OkResult{T}"/>. /// </summary> - [ApiController] - [Route("[controller]")] - [Produces( - MediaTypeNames.Application.Json, - JsonDefaults.CamelCaseMediaType, - JsonDefaults.PascalCaseMediaType)] - public class BaseJellyfinApiController : ControllerBase - { - /// <summary> - /// Create a new <see cref="OkResult{T}"/>. - /// </summary> - /// <param name="value">The value to return.</param> - /// <typeparam name="T">The type to return.</typeparam> - /// <returns>The <see cref="ActionResult{T}"/>.</returns> - protected ActionResult<IEnumerable<T>> Ok<T>(IEnumerable<T>? value) - => new OkResult<IEnumerable<T>?>(value); + /// <param name="value">The value to return.</param> + /// <typeparam name="T">The type to return.</typeparam> + /// <returns>The <see cref="ActionResult{T}"/>.</returns> + protected ActionResult<IEnumerable<T>> Ok<T>(IEnumerable<T>? value) + => new OkResult<IEnumerable<T>?>(value); - /// <summary> - /// Create a new <see cref="OkResult{T}"/>. - /// </summary> - /// <param name="value">The value to return.</param> - /// <typeparam name="T">The type to return.</typeparam> - /// <returns>The <see cref="ActionResult{T}"/>.</returns> - protected ActionResult<T> Ok<T>(T value) - => new OkResult<T>(value); - } + /// <summary> + /// Create a new <see cref="OkResult{T}"/>. + /// </summary> + /// <param name="value">The value to return.</param> + /// <typeparam name="T">The type to return.</typeparam> + /// <returns>The <see cref="ActionResult{T}"/>.</returns> + protected ActionResult<T> Ok<T>(T value) + => new OkResult<T>(value); } diff --git a/Jellyfin.Api/Constants/AuthenticationSchemes.cs b/Jellyfin.Api/Constants/AuthenticationSchemes.cs index bac3379e7..d5c2253e4 100644 --- a/Jellyfin.Api/Constants/AuthenticationSchemes.cs +++ b/Jellyfin.Api/Constants/AuthenticationSchemes.cs @@ -1,13 +1,12 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// <summary> +/// Authentication schemes for user authentication in the API. +/// </summary> +public static class AuthenticationSchemes { /// <summary> - /// Authentication schemes for user authentication in the API. + /// Scheme name for the custom legacy authentication. /// </summary> - public static class AuthenticationSchemes - { - /// <summary> - /// Scheme name for the custom legacy authentication. - /// </summary> - public const string CustomAuthentication = "CustomAuthentication"; - } + public const string CustomAuthentication = "CustomAuthentication"; } diff --git a/Jellyfin.Api/Constants/InternalClaimTypes.cs b/Jellyfin.Api/Constants/InternalClaimTypes.cs index 8323312e5..73c4acb88 100644 --- a/Jellyfin.Api/Constants/InternalClaimTypes.cs +++ b/Jellyfin.Api/Constants/InternalClaimTypes.cs @@ -1,43 +1,42 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// <summary> +/// Internal claim types for authorization. +/// </summary> +public static class InternalClaimTypes { /// <summary> - /// Internal claim types for authorization. + /// User Id. /// </summary> - public static class InternalClaimTypes - { - /// <summary> - /// User Id. - /// </summary> - public const string UserId = "Jellyfin-UserId"; + public const string UserId = "Jellyfin-UserId"; - /// <summary> - /// Device Id. - /// </summary> - public const string DeviceId = "Jellyfin-DeviceId"; + /// <summary> + /// Device Id. + /// </summary> + public const string DeviceId = "Jellyfin-DeviceId"; - /// <summary> - /// Device. - /// </summary> - public const string Device = "Jellyfin-Device"; + /// <summary> + /// Device. + /// </summary> + public const string Device = "Jellyfin-Device"; - /// <summary> - /// Client. - /// </summary> - public const string Client = "Jellyfin-Client"; + /// <summary> + /// Client. + /// </summary> + public const string Client = "Jellyfin-Client"; - /// <summary> - /// Version. - /// </summary> - public const string Version = "Jellyfin-Version"; + /// <summary> + /// Version. + /// </summary> + public const string Version = "Jellyfin-Version"; - /// <summary> - /// Token. - /// </summary> - public const string Token = "Jellyfin-Token"; + /// <summary> + /// Token. + /// </summary> + public const string Token = "Jellyfin-Token"; - /// <summary> - /// Is Api Key. - /// </summary> - public const string IsApiKey = "Jellyfin-IsApiKey"; - } + /// <summary> + /// Is Api Key. + /// </summary> + public const string IsApiKey = "Jellyfin-IsApiKey"; } diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index a72eeea28..5a5a2bf46 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -1,78 +1,77 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// <summary> +/// Policies for the API authorization. +/// </summary> +public static class Policies { /// <summary> - /// Policies for the API authorization. + /// Policy name for default authorization. /// </summary> - public static class Policies - { - /// <summary> - /// Policy name for default authorization. - /// </summary> - public const string DefaultAuthorization = "DefaultAuthorization"; + public const string DefaultAuthorization = "DefaultAuthorization"; - /// <summary> - /// Policy name for requiring first time setup or elevated privileges. - /// </summary> - public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated"; + /// <summary> + /// Policy name for requiring first time setup or elevated privileges. + /// </summary> + public const string FirstTimeSetupOrElevated = "FirstTimeSetupOrElevated"; - /// <summary> - /// Policy name for requiring elevated privileges. - /// </summary> - public const string RequiresElevation = "RequiresElevation"; + /// <summary> + /// Policy name for requiring elevated privileges. + /// </summary> + public const string RequiresElevation = "RequiresElevation"; - /// <summary> - /// Policy name for allowing local access only. - /// </summary> - public const string LocalAccessOnly = "LocalAccessOnly"; + /// <summary> + /// Policy name for allowing local access only. + /// </summary> + public const string LocalAccessOnly = "LocalAccessOnly"; - /// <summary> - /// Policy name for escaping schedule controls. - /// </summary> - public const string IgnoreParentalControl = "IgnoreParentalControl"; + /// <summary> + /// Policy name for escaping schedule controls. + /// </summary> + public const string IgnoreParentalControl = "IgnoreParentalControl"; - /// <summary> - /// Policy name for requiring download permission. - /// </summary> - public const string Download = "Download"; + /// <summary> + /// Policy name for requiring download permission. + /// </summary> + public const string Download = "Download"; - /// <summary> - /// Policy name for requiring first time setup or default permissions. - /// </summary> - public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault"; + /// <summary> + /// Policy name for requiring first time setup or default permissions. + /// </summary> + public const string FirstTimeSetupOrDefault = "FirstTimeSetupOrDefault"; - /// <summary> - /// Policy name for requiring local access or elevated privileges. - /// </summary> - public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation"; + /// <summary> + /// Policy name for requiring local access or elevated privileges. + /// </summary> + public const string LocalAccessOrRequiresElevation = "LocalAccessOrRequiresElevation"; - /// <summary> - /// Policy name for requiring (anonymous) LAN access. - /// </summary> - public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy"; + /// <summary> + /// Policy name for requiring (anonymous) LAN access. + /// </summary> + public const string AnonymousLanAccessPolicy = "AnonymousLanAccessPolicy"; - /// <summary> - /// Policy name for escaping schedule controls or requiring first time setup. - /// </summary> - public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; + /// <summary> + /// Policy name for escaping schedule controls or requiring first time setup. + /// </summary> + public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; - /// <summary> - /// Policy name for accessing SyncPlay. - /// </summary> - public const string SyncPlayHasAccess = "SyncPlayHasAccess"; + /// <summary> + /// Policy name for accessing SyncPlay. + /// </summary> + public const string SyncPlayHasAccess = "SyncPlayHasAccess"; - /// <summary> - /// Policy name for creating a SyncPlay group. - /// </summary> - public const string SyncPlayCreateGroup = "SyncPlayCreateGroup"; + /// <summary> + /// Policy name for creating a SyncPlay group. + /// </summary> + public const string SyncPlayCreateGroup = "SyncPlayCreateGroup"; - /// <summary> - /// Policy name for joining a SyncPlay group. - /// </summary> - public const string SyncPlayJoinGroup = "SyncPlayJoinGroup"; + /// <summary> + /// Policy name for joining a SyncPlay group. + /// </summary> + public const string SyncPlayJoinGroup = "SyncPlayJoinGroup"; - /// <summary> - /// Policy name for accessing a SyncPlay group. - /// </summary> - public const string SyncPlayIsInGroup = "SyncPlayIsInGroup"; - } + /// <summary> + /// Policy name for accessing a SyncPlay group. + /// </summary> + public const string SyncPlayIsInGroup = "SyncPlayIsInGroup"; } diff --git a/Jellyfin.Api/Constants/UserRoles.cs b/Jellyfin.Api/Constants/UserRoles.cs index d9a536e7d..41c7b7cd0 100644 --- a/Jellyfin.Api/Constants/UserRoles.cs +++ b/Jellyfin.Api/Constants/UserRoles.cs @@ -1,23 +1,22 @@ -namespace Jellyfin.Api.Constants +namespace Jellyfin.Api.Constants; + +/// <summary> +/// Constants for user roles used in the authentication and authorization for the API. +/// </summary> +public static class UserRoles { /// <summary> - /// Constants for user roles used in the authentication and authorization for the API. + /// Guest user. /// </summary> - public static class UserRoles - { - /// <summary> - /// Guest user. - /// </summary> - public const string Guest = "Guest"; + public const string Guest = "Guest"; - /// <summary> - /// Regular user with no special privileges. - /// </summary> - public const string User = "User"; + /// <summary> + /// Regular user with no special privileges. + /// </summary> + public const string User = "User"; - /// <summary> - /// Administrator user with elevated privileges. - /// </summary> - public const string Administrator = "Administrator"; - } + /// <summary> + /// Administrator user with elevated privileges. + /// </summary> + public const string Administrator = "Administrator"; } diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index ae45f647f..c3d02976e 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -8,50 +8,49 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +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> - /// Activity log controller. + /// Initializes a new instance of the <see cref="ActivityLogController"/> class. /// </summary> - [Route("System/ActivityLog")] - [Authorize(Policy = Policies.RequiresElevation)] - public class ActivityLogController : BaseJellyfinApiController + /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param> + public ActivityLogController(IActivityManager activityManager) { - 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; - } + _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. Filter log entries if it has user id, or not.</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 async Task<ActionResult<QueryResult<ActivityLogEntry>>> GetLogEntries( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] DateTime? minDate, - [FromQuery] bool? hasUserId) + /// <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. Filter log entries if it has user id, or not.</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 async Task<ActionResult<QueryResult<ActivityLogEntry>>> GetLogEntries( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] DateTime? minDate, + [FromQuery] bool? hasUserId) + { + return await _activityManager.GetPagedResultAsync(new ActivityLogQuery { - return await _activityManager.GetPagedResultAsync(new ActivityLogQuery - { - Skip = startIndex, - Limit = limit, - MinDate = minDate, - HasUserId = hasUserId - }).ConfigureAwait(false); - } + Skip = startIndex, + Limit = limit, + MinDate = minDate, + HasUserId = hasUserId + }).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index 024a15349..991f8cbf2 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -7,70 +7,69 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Authentication controller. +/// </summary> +[Route("Auth")] +public class ApiKeyController : BaseJellyfinApiController { + private readonly IAuthenticationManager _authenticationManager; + /// <summary> - /// Authentication controller. + /// Initializes a new instance of the <see cref="ApiKeyController"/> class. /// </summary> - [Route("Auth")] - public class ApiKeyController : BaseJellyfinApiController + /// <param name="authenticationManager">Instance of <see cref="IAuthenticationManager"/> interface.</param> + public ApiKeyController(IAuthenticationManager authenticationManager) { - private readonly IAuthenticationManager _authenticationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="ApiKeyController"/> class. - /// </summary> - /// <param name="authenticationManager">Instance of <see cref="IAuthenticationManager"/> interface.</param> - public ApiKeyController(IAuthenticationManager authenticationManager) - { - _authenticationManager = authenticationManager; - } + _authenticationManager = authenticationManager; + } - /// <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 async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys() - { - var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false); + /// <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 async Task<ActionResult<QueryResult<AuthenticationInfo>>> GetKeys() + { + var keys = await _authenticationManager.GetApiKeys().ConfigureAwait(false); - return new QueryResult<AuthenticationInfo>(keys); - } + return new QueryResult<AuthenticationInfo>(keys); + } - /// <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 async Task<ActionResult> CreateKey([FromQuery, Required] string app) - { - await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); + /// <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 async Task<ActionResult> CreateKey([FromQuery, Required] string app) + { + await _authenticationManager.CreateApiKey(app).ConfigureAwait(false); - return NoContent(); - } + 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 async Task<ActionResult> RevokeKey([FromRoute, Required] string key) - { - await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false); + /// <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 async Task<ActionResult> RevokeKey([FromRoute, Required] string key) + { + await _authenticationManager.DeleteApiKey(key).ConfigureAwait(false); - return NoContent(); - } + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index c8ac2ed52..069e7311b 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -17,464 +17,463 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The artists controller. +/// </summary> +[Route("Artists")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class ArtistsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + /// <summary> - /// The artists controller. + /// Initializes a new instance of the <see cref="ArtistsController"/> class. /// </summary> - [Route("Artists")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ArtistsController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public ArtistsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - - /// <summary> - /// Initializes a new instance of the <see cref="ArtistsController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - public ArtistsController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } + + /// <summary> + /// Gets all artists from a given item, folder, or the entire library. + /// </summary> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <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="searchTerm">Optional. Search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Total record count.</param> + /// <response code="200">Artists returned.</response> + /// <returns>An <see cref="OkResult"/> containing the artists.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetArtists( + [FromQuery] double? minCommunityRating, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? person, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + User? user = null; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); + + if (userId.HasValue && !userId.Equals(default)) { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; + user = _userManager.GetUserById(userId.Value); } - /// <summary> - /// Gets all artists from a given item, folder, or the entire library. - /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <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="searchTerm">Optional. Search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> - /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Total record count.</param> - /// <response code="200">Artists returned.</response> - /// <returns>An <see cref="OkResult"/> containing the artists.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetArtists( - [FromQuery] double? minCommunityRating, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) + var query = new InternalItemsQuery(user) { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + GenreIds = genreIds, + StudioIds = studioIds, + Person = person, + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, + MinCommunityRating = minCommunityRating, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) + }; - User? user = null; - BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); - - if (userId.HasValue && !userId.Equals(default)) + if (parentId.HasValue) + { + if (parentItem is Folder) { - user = _userManager.GetUserById(userId.Value); + query.AncestorIds = new[] { parentId.Value }; } - - var query = new InternalItemsQuery(user) + else { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - MediaTypes = mediaTypes, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = tags, - OfficialRatings = officialRatings, - Genres = genres, - GenreIds = genreIds, - StudioIds = studioIds, - Person = person, - PersonIds = personIds, - PersonTypes = personTypes, - Years = years, - MinCommunityRating = minCommunityRating, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) - }; - - if (parentId.HasValue) + query.ItemIds = new[] { parentId.Value }; + } + } + + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => { - if (parentItem is Folder) + try { - query.AncestorIds = new[] { parentId.Value }; + return _libraryManager.GetStudio(i); } - else + catch { - query.ItemIds = new[] { parentId.Value }; + return null; } - } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } - // Studios - if (studios.Length != 0) + foreach (var filter in filters) + { + switch (filter) { - query.StudioIds = studios.Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; } + } - foreach (var filter in filters) + var result = _libraryManager.GetArtists(query); + + var dtos = result.Items.Select(i => + { + var (baseItem, itemCounts) = i; + var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); + + if (includeItemTypes.Length != 0) { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } + dto.ChildCount = itemCounts.ItemCount; + dto.ProgramCount = itemCounts.ProgramCount; + dto.SeriesCount = itemCounts.SeriesCount; + dto.EpisodeCount = itemCounts.EpisodeCount; + dto.MovieCount = itemCounts.MovieCount; + dto.TrailerCount = itemCounts.TrailerCount; + dto.AlbumCount = itemCounts.AlbumCount; + dto.SongCount = itemCounts.SongCount; + dto.ArtistCount = itemCounts.ArtistCount; } - var result = _libraryManager.GetArtists(query); + return dto; + }); - var dtos = result.Items.Select(i => - { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); + return new QueryResult<BaseItemDto>( + query.StartIndex, + result.TotalRecordCount, + dtos.ToArray()); + } - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } + /// <summary> + /// Gets all album artists from a given item, folder, or the entire library. + /// </summary> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <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="searchTerm">Optional. Search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Total record count.</param> + /// <response code="200">Album artists returned.</response> + /// <returns>An <see cref="OkResult"/> containing the album artists.</returns> + [HttpGet("AlbumArtists")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists( + [FromQuery] double? minCommunityRating, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? person, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - return dto; - }); + User? user = null; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); - return new QueryResult<BaseItemDto>( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); + if (userId.HasValue && !userId.Equals(default)) + { + user = _userManager.GetUserById(userId.Value); } - /// <summary> - /// Gets all album artists from a given item, folder, or the entire library. - /// </summary> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <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="searchTerm">Optional. Search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> - /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Total record count.</param> - /// <response code="200">Album artists returned.</response> - /// <returns>An <see cref="OkResult"/> containing the album artists.</returns> - [HttpGet("AlbumArtists")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists( - [FromQuery] double? minCommunityRating, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) + var query = new InternalItemsQuery(user) { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - User? user = null; - BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + GenreIds = genreIds, + StudioIds = studioIds, + Person = person, + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, + MinCommunityRating = minCommunityRating, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) + }; - if (userId.HasValue && !userId.Equals(default)) + if (parentId.HasValue) + { + if (parentItem is Folder) { - user = _userManager.GetUserById(userId.Value); + query.AncestorIds = new[] { parentId.Value }; } - - var query = new InternalItemsQuery(user) + else { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - MediaTypes = mediaTypes, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = tags, - OfficialRatings = officialRatings, - Genres = genres, - GenreIds = genreIds, - StudioIds = studioIds, - Person = person, - PersonIds = personIds, - PersonTypes = personTypes, - Years = years, - MinCommunityRating = minCommunityRating, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) - }; - - if (parentId.HasValue) + query.ItemIds = new[] { parentId.Value }; + } + } + + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => { - if (parentItem is Folder) + try { - query.AncestorIds = new[] { parentId.Value }; + return _libraryManager.GetStudio(i); } - else + catch { - query.ItemIds = new[] { parentId.Value }; + return null; } - } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } - // Studios - if (studios.Length != 0) + foreach (var filter in filters) + { + switch (filter) { - query.StudioIds = studios.Select(i => - { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; } + } - foreach (var filter in filters) - { - switch (filter) - { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - } - } + var result = _libraryManager.GetAlbumArtists(query); - var result = _libraryManager.GetAlbumArtists(query); + var dtos = result.Items.Select(i => + { + var (baseItem, itemCounts) = i; + var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - var dtos = result.Items.Select(i => + if (includeItemTypes.Length != 0) { - var (baseItem, itemCounts) = i; - var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes.Length != 0) - { - dto.ChildCount = itemCounts.ItemCount; - dto.ProgramCount = itemCounts.ProgramCount; - dto.SeriesCount = itemCounts.SeriesCount; - dto.EpisodeCount = itemCounts.EpisodeCount; - dto.MovieCount = itemCounts.MovieCount; - dto.TrailerCount = itemCounts.TrailerCount; - dto.AlbumCount = itemCounts.AlbumCount; - dto.SongCount = itemCounts.SongCount; - dto.ArtistCount = itemCounts.ArtistCount; - } - - return dto; - }); + dto.ChildCount = itemCounts.ItemCount; + dto.ProgramCount = itemCounts.ProgramCount; + dto.SeriesCount = itemCounts.SeriesCount; + dto.EpisodeCount = itemCounts.EpisodeCount; + dto.MovieCount = itemCounts.MovieCount; + dto.TrailerCount = itemCounts.TrailerCount; + dto.AlbumCount = itemCounts.AlbumCount; + dto.SongCount = itemCounts.SongCount; + dto.ArtistCount = itemCounts.ArtistCount; + } - return new QueryResult<BaseItemDto>( - query.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); - } + return dto; + }); - /// <summary> - /// Gets an artist by name. - /// </summary> - /// <param name="name">Studio name.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Artist returned.</response> - /// <returns>An <see cref="OkResult"/> containing the artist.</returns> - [HttpGet("{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId) - { - var dtoOptions = new DtoOptions().AddClientFields(User); + return new QueryResult<BaseItemDto>( + query.StartIndex, + result.TotalRecordCount, + dtos.ToArray()); + } - var item = _libraryManager.GetArtist(name, dtoOptions); + /// <summary> + /// Gets an artist by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Artist returned.</response> + /// <returns>An <see cref="OkResult"/> containing the artist.</returns> + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId) + { + var dtoOptions = new DtoOptions().AddClientFields(User); - if (userId.HasValue && !userId.Value.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetArtist(name, dtoOptions); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + if (userId.HasValue && !userId.Value.Equals(default)) + { + var user = _userManager.GetUserById(userId.Value); - return _dtoService.GetBaseItemDto(item, dtoOptions); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); } + + return _dtoService.GetBaseItemDto(item, dtoOptions); } } diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index 94f7a7b82..968193a6f 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -10,355 +10,354 @@ using MediaBrowser.Model.Dlna; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The audio controller. +/// </summary> +// TODO: In order to authenticate this in the future, Dlna playback will require updating +public class AudioController : BaseJellyfinApiController { + private readonly AudioHelper _audioHelper; + + private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; + /// <summary> - /// The audio controller. + /// Initializes a new instance of the <see cref="AudioController"/> class. /// </summary> - // TODO: In order to authenticate this in the future, Dlna playback will require updating - public class AudioController : BaseJellyfinApiController + /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param> + public AudioController(AudioHelper audioHelper) { - private readonly AudioHelper _audioHelper; - - private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; - - /// <summary> - /// Initializes a new instance of the <see cref="AudioController"/> class. - /// </summary> - /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param> - public AudioController(AudioHelper audioHelper) - { - _audioHelper = audioHelper; - } + _audioHelper = audioHelper; + } - /// <summary> - /// Gets an audio stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The audio container.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Audio stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("{itemId}/stream", Name = "GetAudioStream")] - [HttpHead("{itemId}/stream", Name = "HeadAudioStream")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesAudioFile] - public async Task<ActionResult> GetAudioStream( - [FromRoute, Required] Guid itemId, - [FromQuery] string? container, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string>? streamOptions) + /// <summary> + /// Gets an audio stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The audio container.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/stream", Name = "GetAudioStream")] + [HttpHead("{itemId}/stream", Name = "HeadAudioStream")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] + public async Task<ActionResult> GetAudioStream( + [FromRoute, Required] Guid itemId, + [FromQuery] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string>? streamOptions) + { + StreamingRequestDto streamingRequest = new StreamingRequestDto { - StreamingRequestDto streamingRequest = new StreamingRequestDto - { - Id = itemId, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Static, - StreamOptions = streamOptions - }; + Id = itemId, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Static, + StreamOptions = streamOptions + }; - return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); - } + return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); + } - /// <summary> - /// Gets an audio stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The audio container.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Audio stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")] - [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesAudioFile] - public async Task<ActionResult> GetAudioStreamByContainer( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string container, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string>? streamOptions) + /// <summary> + /// Gets an audio stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The audio container.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")] + [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesAudioFile] + public async Task<ActionResult> GetAudioStreamByContainer( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string>? streamOptions) + { + StreamingRequestDto streamingRequest = new StreamingRequestDto { - StreamingRequestDto streamingRequest = new StreamingRequestDto - { - Id = itemId, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Static, - StreamOptions = streamOptions - }; + Id = itemId, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Static, + StreamOptions = streamOptions + }; - return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); - } + return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs index d3ea41201..3c2c4b4db 100644 --- a/Jellyfin.Api/Controllers/BrandingController.cs +++ b/Jellyfin.Api/Controllers/BrandingController.cs @@ -4,54 +4,53 @@ using MediaBrowser.Model.Branding; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Branding controller. +/// </summary> +public class BrandingController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _serverConfigurationManager; + /// <summary> - /// Branding controller. + /// Initializes a new instance of the <see cref="BrandingController"/> class. /// </summary> - public class BrandingController : BaseJellyfinApiController + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public BrandingController(IServerConfigurationManager serverConfigurationManager) { - private readonly IServerConfigurationManager _serverConfigurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="BrandingController"/> class. - /// </summary> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public BrandingController(IServerConfigurationManager serverConfigurationManager) - { - _serverConfigurationManager = serverConfigurationManager; - } + _serverConfigurationManager = serverConfigurationManager; + } - /// <summary> - /// Gets branding configuration. - /// </summary> - /// <response code="200">Branding configuration returned.</response> - /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns> - [HttpGet("Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BrandingOptions> GetBrandingOptions() - { - return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - } + /// <summary> + /// Gets branding configuration. + /// </summary> + /// <response code="200">Branding configuration returned.</response> + /// <returns>An <see cref="OkResult"/> containing the branding configuration.</returns> + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BrandingOptions> GetBrandingOptions() + { + return _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + } - /// <summary> - /// Gets branding css. - /// </summary> - /// <response code="200">Branding css returned.</response> - /// <response code="204">No branding css configured.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the branding css if exist, - /// or a <see cref="NoContentResult"/> if the css is not configured. - /// </returns> - [HttpGet("Css")] - [HttpGet("Css.css", Name = "GetBrandingCss_2")] - [Produces("text/css")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult<string> GetBrandingCss() - { - var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - return options.CustomCss ?? string.Empty; - } + /// <summary> + /// Gets branding css. + /// </summary> + /// <response code="200">Branding css returned.</response> + /// <response code="204">No branding css configured.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the branding css if exist, + /// or a <see cref="NoContentResult"/> if the css is not configured. + /// </returns> + [HttpGet("Css")] + [HttpGet("Css.css", Name = "GetBrandingCss_2")] + [Produces("text/css")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult<string> GetBrandingCss() + { + var options = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + return options.CustomCss ?? string.Empty; } } diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs index d5b589a3f..573b7069c 100644 --- a/Jellyfin.Api/Controllers/ChannelsController.cs +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -18,234 +18,233 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Channels Controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class ChannelsController : BaseJellyfinApiController { + private readonly IChannelManager _channelManager; + private readonly IUserManager _userManager; + /// <summary> - /// Channels Controller. + /// Initializes a new instance of the <see cref="ChannelsController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ChannelsController : BaseJellyfinApiController + /// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public ChannelsController(IChannelManager channelManager, IUserManager userManager) { - private readonly IChannelManager _channelManager; - private readonly IUserManager _userManager; + _channelManager = channelManager; + _userManager = userManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="ChannelsController"/> class. - /// </summary> - /// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public ChannelsController(IChannelManager channelManager, IUserManager userManager) + /// <summary> + /// Gets available channels. + /// </summary> + /// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</param> + /// <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="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param> + /// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param> + /// <param name="isFavorite">Optional. Filter by channels that are favorite.</param> + /// <response code="200">Channels returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channels.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetChannels( + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? supportsLatestItems, + [FromQuery] bool? supportsMediaDeletion, + [FromQuery] bool? isFavorite) + { + return _channelManager.GetChannels(new ChannelQuery { - _channelManager = channelManager; - _userManager = userManager; - } + Limit = limit, + StartIndex = startIndex, + UserId = userId ?? Guid.Empty, + SupportsLatestItems = supportsLatestItems, + SupportsMediaDeletion = supportsMediaDeletion, + IsFavorite = isFavorite + }); + } - /// <summary> - /// Gets available channels. - /// </summary> - /// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</param> - /// <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="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param> - /// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param> - /// <param name="isFavorite">Optional. Filter by channels that are favorite.</param> - /// <response code="200">Channels returned.</response> - /// <returns>An <see cref="OkResult"/> containing the channels.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetChannels( - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? supportsLatestItems, - [FromQuery] bool? supportsMediaDeletion, - [FromQuery] bool? isFavorite) - { - return _channelManager.GetChannels(new ChannelQuery - { - Limit = limit, - StartIndex = startIndex, - UserId = userId ?? Guid.Empty, - SupportsLatestItems = supportsLatestItems, - SupportsMediaDeletion = supportsMediaDeletion, - IsFavorite = isFavorite - }); - } + /// <summary> + /// Get all channel features. + /// </summary> + /// <response code="200">All channel features returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> + [HttpGet("Features")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures() + { + return _channelManager.GetAllChannelFeatures(); + } - /// <summary> - /// Get all channel features. - /// </summary> - /// <response code="200">All channel features returned.</response> - /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> - [HttpGet("Features")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures() - { - return _channelManager.GetAllChannelFeatures(); - } + /// <summary> + /// Get channel features. + /// </summary> + /// <param name="channelId">Channel id.</param> + /// <response code="200">Channel features returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> + [HttpGet("{channelId}/Features")] + public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId) + { + return _channelManager.GetChannelFeatures(channelId); + } - /// <summary> - /// Get channel features. - /// </summary> - /// <param name="channelId">Channel id.</param> - /// <response code="200">Channel features returned.</response> - /// <returns>An <see cref="OkResult"/> containing the channel features.</returns> - [HttpGet("{channelId}/Features")] - public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId) - { - return _channelManager.GetChannelFeatures(channelId); - } + /// <summary> + /// Get channel items. + /// </summary> + /// <param name="channelId">Channel Id.</param> + /// <param name="folderId">Optional. Folder Id.</param> + /// <param name="userId">Optional. User Id.</param> + /// <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="sortOrder">Optional. Sort Order - Ascending,Descending.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <response code="200">Channel items returned.</response> + /// <returns> + /// A <see cref="Task"/> representing the request to get the channel items. + /// The task result contains an <see cref="OkResult"/> containing the channel items. + /// </returns> + [HttpGet("{channelId}/Items")] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems( + [FromRoute, Required] Guid channelId, + [FromQuery] Guid? folderId, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - /// <summary> - /// Get channel items. - /// </summary> - /// <param name="channelId">Channel Id.</param> - /// <param name="folderId">Optional. Folder Id.</param> - /// <param name="userId">Optional. User Id.</param> - /// <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="sortOrder">Optional. Sort Order - Ascending,Descending.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <response code="200">Channel items returned.</response> - /// <returns> - /// A <see cref="Task"/> representing the request to get the channel items. - /// The task result contains an <see cref="OkResult"/> containing the channel items. - /// </returns> - [HttpGet("{channelId}/Items")] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems( - [FromRoute, Required] Guid channelId, - [FromQuery] Guid? folderId, - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) + var query = new InternalItemsQuery(user) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + Limit = limit, + StartIndex = startIndex, + ChannelIds = new[] { channelId }, + ParentId = folderId ?? Guid.Empty, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + DtoOptions = new DtoOptions { Fields = fields } + }; - var query = new InternalItemsQuery(user) - { - Limit = limit, - StartIndex = startIndex, - ChannelIds = new[] { channelId }, - ParentId = folderId ?? Guid.Empty, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - DtoOptions = new DtoOptions { Fields = fields } - }; - - foreach (var filter in filters) + foreach (var filter in filters) + { + switch (filter) { - switch (filter) - { - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - } + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; } - - return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false); } - /// <summary> - /// Gets latest channel items. - /// </summary> - /// <param name="userId">Optional. User Id.</param> - /// <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="filters">Optional. Specify additional filters to apply.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param> - /// <response code="200">Latest channel items returned.</response> - /// <returns> - /// A <see cref="Task"/> representing the request to get the latest channel items. - /// The task result contains an <see cref="OkResult"/> containing the latest channel items. - /// </returns> - [HttpGet("Items/Latest")] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems( - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false); + } - var query = new InternalItemsQuery(user) - { - Limit = limit, - StartIndex = startIndex, - ChannelIds = channelIds, - DtoOptions = new DtoOptions { Fields = fields } - }; + /// <summary> + /// Gets latest channel items. + /// </summary> + /// <param name="userId">Optional. User Id.</param> + /// <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="filters">Optional. Specify additional filters to apply.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param> + /// <response code="200">Latest channel items returned.</response> + /// <returns> + /// A <see cref="Task"/> representing the request to get the latest channel items. + /// The task result contains an <see cref="OkResult"/> containing the latest channel items. + /// </returns> + [HttpGet("Items/Latest")] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems( + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - foreach (var filter in filters) + var query = new InternalItemsQuery(user) + { + Limit = limit, + StartIndex = startIndex, + ChannelIds = channelIds, + DtoOptions = new DtoOptions { Fields = fields } + }; + + foreach (var filter in filters) + { + switch (filter) { - switch (filter) - { - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - } + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; } - - return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false); } + + return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Controllers/ClientLogController.cs b/Jellyfin.Api/Controllers/ClientLogController.cs index ed073a687..21c31bc93 100644 --- a/Jellyfin.Api/Controllers/ClientLogController.cs +++ b/Jellyfin.Api/Controllers/ClientLogController.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.ClientLogDtos; using MediaBrowser.Controller.ClientEvent; using MediaBrowser.Controller.Configuration; @@ -11,71 +10,70 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Client log controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class ClientLogController : BaseJellyfinApiController { + private const int MaxDocumentSize = 1_000_000; + private readonly IClientEventLogger _clientEventLogger; + private readonly IServerConfigurationManager _serverConfigurationManager; + /// <summary> - /// Client log controller. + /// Initializes a new instance of the <see cref="ClientLogController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ClientLogController : BaseJellyfinApiController + /// <param name="clientEventLogger">Instance of the <see cref="IClientEventLogger"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public ClientLogController( + IClientEventLogger clientEventLogger, + IServerConfigurationManager serverConfigurationManager) { - private const int MaxDocumentSize = 1_000_000; - private readonly IClientEventLogger _clientEventLogger; - private readonly IServerConfigurationManager _serverConfigurationManager; + _clientEventLogger = clientEventLogger; + _serverConfigurationManager = serverConfigurationManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="ClientLogController"/> class. - /// </summary> - /// <param name="clientEventLogger">Instance of the <see cref="IClientEventLogger"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public ClientLogController( - IClientEventLogger clientEventLogger, - IServerConfigurationManager serverConfigurationManager) + /// <summary> + /// Upload a document. + /// </summary> + /// <response code="200">Document saved.</response> + /// <response code="403">Event logging disabled.</response> + /// <response code="413">Upload size too large.</response> + /// <returns>Create response.</returns> + [HttpPost("Document")] + [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + [AcceptsFile(MediaTypeNames.Text.Plain)] + [RequestSizeLimit(MaxDocumentSize)] + public async Task<ActionResult<ClientLogDocumentResponseDto>> LogFile() + { + if (!_serverConfigurationManager.Configuration.AllowClientLogUpload) { - _clientEventLogger = clientEventLogger; - _serverConfigurationManager = serverConfigurationManager; + return Forbid(); } - /// <summary> - /// Upload a document. - /// </summary> - /// <response code="200">Document saved.</response> - /// <response code="403">Event logging disabled.</response> - /// <response code="413">Upload size too large.</response> - /// <returns>Create response.</returns> - [HttpPost("Document")] - [ProducesResponseType(typeof(ClientLogDocumentResponseDto), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] - [AcceptsFile(MediaTypeNames.Text.Plain)] - [RequestSizeLimit(MaxDocumentSize)] - public async Task<ActionResult<ClientLogDocumentResponseDto>> LogFile() + if (Request.ContentLength > MaxDocumentSize) { - if (!_serverConfigurationManager.Configuration.AllowClientLogUpload) - { - return Forbid(); - } - - if (Request.ContentLength > MaxDocumentSize) - { - // Manually validate to return proper status code. - return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes"); - } - - var (clientName, clientVersion) = GetRequestInformation(); - var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body) - .ConfigureAwait(false); - return Ok(new ClientLogDocumentResponseDto(fileName)); + // Manually validate to return proper status code. + return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Payload must be less than {MaxDocumentSize:N0} bytes"); } - private (string ClientName, string ClientVersion) GetRequestInformation() - { - var clientName = HttpContext.User.GetClient() ?? "unknown-client"; - var clientVersion = HttpContext.User.GetIsApiKey() - ? "apikey" - : HttpContext.User.GetVersion() ?? "unknown-version"; + var (clientName, clientVersion) = GetRequestInformation(); + var fileName = await _clientEventLogger.WriteDocumentAsync(clientName, clientVersion, Request.Body) + .ConfigureAwait(false); + return Ok(new ClientLogDocumentResponseDto(fileName)); + } - return (clientName, clientVersion); - } + private (string ClientName, string ClientVersion) GetRequestInformation() + { + var clientName = HttpContext.User.GetClient() ?? "unknown-client"; + var clientVersion = HttpContext.User.GetIsApiKey() + ? "apikey" + : HttpContext.User.GetVersion() ?? "unknown-version"; + + return (clientName, clientVersion); } } diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index effc9ed7a..5a4a9bf07 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -11,101 +11,100 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The collection controller. +/// </summary> +[Route("Collections")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class CollectionController : BaseJellyfinApiController { + private readonly ICollectionManager _collectionManager; + private readonly IDtoService _dtoService; + /// <summary> - /// The collection controller. + /// Initializes a new instance of the <see cref="CollectionController"/> class. /// </summary> - [Route("Collections")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class CollectionController : BaseJellyfinApiController + /// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param> + /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> + public CollectionController( + ICollectionManager collectionManager, + IDtoService dtoService) { - private readonly ICollectionManager _collectionManager; - private readonly IDtoService _dtoService; + _collectionManager = collectionManager; + _dtoService = dtoService; + } - /// <summary> - /// Initializes a new instance of the <see cref="CollectionController"/> class. - /// </summary> - /// <param name="collectionManager">Instance of <see cref="ICollectionManager"/> interface.</param> - /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> - public CollectionController( - ICollectionManager collectionManager, - IDtoService dtoService) - { - _collectionManager = collectionManager; - _dtoService = dtoService; - } + /// <summary> + /// Creates a new collection. + /// </summary> + /// <param name="name">The name of the collection.</param> + /// <param name="ids">Item Ids to add to the collection.</param> + /// <param name="parentId">Optional. Create the collection within a specific folder.</param> + /// <param name="isLocked">Whether or not to lock the new collection.</param> + /// <response code="200">Collection created.</response> + /// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<CollectionCreationResult>> CreateCollection( + [FromQuery] string? name, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids, + [FromQuery] Guid? parentId, + [FromQuery] bool isLocked = false) + { + var userId = User.GetUserId(); - /// <summary> - /// Creates a new collection. - /// </summary> - /// <param name="name">The name of the collection.</param> - /// <param name="ids">Item Ids to add to the collection.</param> - /// <param name="parentId">Optional. Create the collection within a specific folder.</param> - /// <param name="isLocked">Whether or not to lock the new collection.</param> - /// <response code="200">Collection created.</response> - /// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns> - [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<CollectionCreationResult>> CreateCollection( - [FromQuery] string? name, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids, - [FromQuery] Guid? parentId, - [FromQuery] bool isLocked = false) + var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions { - var userId = User.GetUserId(); - - var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions - { - IsLocked = isLocked, - Name = name, - ParentId = parentId, - ItemIdList = ids, - UserIds = new[] { userId } - }).ConfigureAwait(false); + IsLocked = isLocked, + Name = name, + ParentId = parentId, + ItemIdList = ids, + UserIds = new[] { userId } + }).ConfigureAwait(false); - var dtoOptions = new DtoOptions().AddClientFields(User); + var dtoOptions = new DtoOptions().AddClientFields(User); - var dto = _dtoService.GetBaseItemDto(item, dtoOptions); + var dto = _dtoService.GetBaseItemDto(item, dtoOptions); - return new CollectionCreationResult - { - Id = dto.Id - }; - } - - /// <summary> - /// Adds items to a collection. - /// </summary> - /// <param name="collectionId">The collection id.</param> - /// <param name="ids">Item ids, comma delimited.</param> - /// <response code="204">Items added to collection.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("{collectionId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> AddToCollection( - [FromRoute, Required] Guid collectionId, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + return new CollectionCreationResult { - await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); - return NoContent(); - } + Id = dto.Id + }; + } - /// <summary> - /// Removes items from a collection. - /// </summary> - /// <param name="collectionId">The collection id.</param> - /// <param name="ids">Item ids, comma delimited.</param> - /// <response code="204">Items removed from collection.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpDelete("{collectionId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveFromCollection( - [FromRoute, Required] Guid collectionId, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) - { - await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false); - return NoContent(); - } + /// <summary> + /// Adds items to a collection. + /// </summary> + /// <param name="collectionId">The collection id.</param> + /// <param name="ids">Item ids, comma delimited.</param> + /// <response code="204">Items added to collection.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("{collectionId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> AddToCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); + return NoContent(); + } + + /// <summary> + /// Removes items from a collection. + /// </summary> + /// <param name="collectionId">The collection id.</param> + /// <param name="ids">Item ids, comma delimited.</param> + /// <response code="204">Items removed from collection.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpDelete("{collectionId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RemoveFromCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index a00ac1b0a..d53d7cefd 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -13,124 +13,123 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Configuration Controller. +/// </summary> +[Route("System")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class ConfigurationController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _configurationManager; + private readonly IMediaEncoder _mediaEncoder; + + private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options; + /// <summary> - /// Configuration Controller. + /// Initializes a new instance of the <see cref="ConfigurationController"/> class. /// </summary> - [Route("System")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ConfigurationController : BaseJellyfinApiController + /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + public ConfigurationController( + IServerConfigurationManager configurationManager, + IMediaEncoder mediaEncoder) { - private readonly IServerConfigurationManager _configurationManager; - private readonly IMediaEncoder _mediaEncoder; + _configurationManager = configurationManager; + _mediaEncoder = mediaEncoder; + } - private readonly JsonSerializerOptions _serializerOptions = JsonDefaults.Options; + /// <summary> + /// Gets application configuration. + /// </summary> + /// <response code="200">Application configuration returned.</response> + /// <returns>Application configuration.</returns> + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<ServerConfiguration> GetConfiguration() + { + return _configurationManager.Configuration; + } - /// <summary> - /// Initializes a new instance of the <see cref="ConfigurationController"/> class. - /// </summary> - /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - public ConfigurationController( - IServerConfigurationManager configurationManager, - IMediaEncoder mediaEncoder) - { - _configurationManager = configurationManager; - _mediaEncoder = mediaEncoder; - } + /// <summary> + /// Updates application configuration. + /// </summary> + /// <param name="configuration">Configuration.</param> + /// <response code="204">Configuration updated.</response> + /// <returns>Update status.</returns> + [HttpPost("Configuration")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration) + { + _configurationManager.ReplaceConfiguration(configuration); + return NoContent(); + } - /// <summary> - /// Gets application configuration. - /// </summary> - /// <response code="200">Application configuration returned.</response> - /// <returns>Application configuration.</returns> - [HttpGet("Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<ServerConfiguration> GetConfiguration() - { - return _configurationManager.Configuration; - } + /// <summary> + /// Gets a named configuration. + /// </summary> + /// <param name="key">Configuration key.</param> + /// <response code="200">Configuration returned.</response> + /// <returns>Configuration.</returns> + [HttpGet("Configuration/{key}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile(MediaTypeNames.Application.Json)] + public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string key) + { + return _configurationManager.GetConfiguration(key); + } - /// <summary> - /// Updates application configuration. - /// </summary> - /// <param name="configuration">Configuration.</param> - /// <response code="204">Configuration updated.</response> - /// <returns>Update status.</returns> - [HttpPost("Configuration")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateConfiguration([FromBody, Required] ServerConfiguration configuration) - { - _configurationManager.ReplaceConfiguration(configuration); - return NoContent(); - } + /// <summary> + /// Updates named configuration. + /// </summary> + /// <param name="key">Configuration key.</param> + /// <param name="configuration">Configuration.</param> + /// <response code="204">Named configuration updated.</response> + /// <returns>Update status.</returns> + [HttpPost("Configuration/{key}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateNamedConfiguration([FromRoute, Required] string key, [FromBody, Required] JsonDocument configuration) + { + var configurationType = _configurationManager.GetConfigurationType(key); + var deserializedConfiguration = configuration.Deserialize(configurationType, _serializerOptions); - /// <summary> - /// Gets a named configuration. - /// </summary> - /// <param name="key">Configuration key.</param> - /// <response code="200">Configuration returned.</response> - /// <returns>Configuration.</returns> - [HttpGet("Configuration/{key}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile(MediaTypeNames.Application.Json)] - public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string key) + if (deserializedConfiguration is null) { - return _configurationManager.GetConfiguration(key); + throw new ArgumentException("Body doesn't contain a valid configuration"); } - /// <summary> - /// Updates named configuration. - /// </summary> - /// <param name="key">Configuration key.</param> - /// <param name="configuration">Configuration.</param> - /// <response code="204">Named configuration updated.</response> - /// <returns>Update status.</returns> - [HttpPost("Configuration/{key}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateNamedConfiguration([FromRoute, Required] string key, [FromBody, Required] JsonDocument configuration) - { - var configurationType = _configurationManager.GetConfigurationType(key); - var deserializedConfiguration = configuration.Deserialize(configurationType, _serializerOptions); - - if (deserializedConfiguration is null) - { - throw new ArgumentException("Body doesn't contain a valid configuration"); - } - - _configurationManager.SaveConfiguration(key, deserializedConfiguration); - return NoContent(); - } + _configurationManager.SaveConfiguration(key, deserializedConfiguration); + return NoContent(); + } - /// <summary> - /// Gets a default MetadataOptions object. - /// </summary> - /// <response code="200">Metadata options returned.</response> - /// <returns>Default MetadataOptions.</returns> - [HttpGet("Configuration/MetadataOptions/Default")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<MetadataOptions> GetDefaultMetadataOptions() - { - return new MetadataOptions(); - } + /// <summary> + /// Gets a default MetadataOptions object. + /// </summary> + /// <response code="200">Metadata options returned.</response> + /// <returns>Default MetadataOptions.</returns> + [HttpGet("Configuration/MetadataOptions/Default")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<MetadataOptions> GetDefaultMetadataOptions() + { + return new MetadataOptions(); + } - /// <summary> - /// Updates the path to the media encoder. - /// </summary> - /// <param name="mediaEncoderPath">Media encoder path form body.</param> - /// <response code="204">Media encoder path updated.</response> - /// <returns>Status.</returns> - [HttpPost("MediaEncoder/Path")] - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath) - { - _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType); - return NoContent(); - } + /// <summary> + /// Updates the path to the media encoder. + /// </summary> + /// <param name="mediaEncoderPath">Media encoder path form body.</param> + /// <response code="204">Media encoder path updated.</response> + /// <returns>Status.</returns> + [HttpPost("MediaEncoder/Path")] + [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath) + { + _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index 3894e6c5f..f7e978bad 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -14,103 +14,102 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The dashboard controller. +/// </summary> +[Route("")] +public class DashboardController : BaseJellyfinApiController { + private readonly ILogger<DashboardController> _logger; + private readonly IPluginManager _pluginManager; + /// <summary> - /// The dashboard controller. + /// Initializes a new instance of the <see cref="DashboardController"/> class. /// </summary> - [Route("")] - public class DashboardController : BaseJellyfinApiController + /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param> + /// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param> + public DashboardController( + ILogger<DashboardController> logger, + IPluginManager pluginManager) { - private readonly ILogger<DashboardController> _logger; - private readonly IPluginManager _pluginManager; + _logger = logger; + _pluginManager = pluginManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="DashboardController"/> class. - /// </summary> - /// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param> - /// <param name="pluginManager">Instance of <see cref="IPluginManager"/> interface.</param> - public DashboardController( - ILogger<DashboardController> logger, - IPluginManager pluginManager) - { - _logger = logger; - _pluginManager = pluginManager; - } + /// <summary> + /// Gets the configuration pages. + /// </summary> + /// <param name="enableInMainMenu">Whether to enable in the main menu.</param> + /// <response code="200">ConfigurationPages returned.</response> + /// <response code="404">Server still loading.</response> + /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns> + [HttpGet("web/ConfigurationPages")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages( + [FromQuery] bool? enableInMainMenu) + { + var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList(); - /// <summary> - /// Gets the configuration pages. - /// </summary> - /// <param name="enableInMainMenu">Whether to enable in the main menu.</param> - /// <response code="200">ConfigurationPages returned.</response> - /// <response code="404">Server still loading.</response> - /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns> - [HttpGet("web/ConfigurationPages")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages( - [FromQuery] bool? enableInMainMenu) + if (enableInMainMenu.HasValue) { - var configPages = _pluginManager.Plugins.SelectMany(GetConfigPages).ToList(); - - if (enableInMainMenu.HasValue) - { - configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList(); - } - - return configPages; + configPages = configPages.Where(p => p.EnableInMainMenu == enableInMainMenu.Value).ToList(); } - /// <summary> - /// Gets a dashboard configuration page. - /// </summary> - /// <param name="name">The name of the page.</param> - /// <response code="200">ConfigurationPage returned.</response> - /// <response code="404">Plugin configuration page not found.</response> - /// <returns>The configuration page.</returns> - [HttpGet("web/ConfigurationPage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")] - public ActionResult GetDashboardConfigurationPage([FromQuery] string? name) - { - var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase)); - if (altPage is null) - { - return NotFound(); - } - - IPlugin plugin = altPage.Item2; - string resourcePath = altPage.Item1.EmbeddedResourcePath; - Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath); - if (stream is null) - { - _logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name); - return NotFound(); - } + return configPages; + } - return File(stream, MimeTypes.GetMimeType(resourcePath)); + /// <summary> + /// Gets a dashboard configuration page. + /// </summary> + /// <param name="name">The name of the page.</param> + /// <response code="200">ConfigurationPage returned.</response> + /// <response code="404">Plugin configuration page not found.</response> + /// <returns>The configuration page.</returns> + [HttpGet("web/ConfigurationPage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")] + public ActionResult GetDashboardConfigurationPage([FromQuery] string? name) + { + var altPage = GetPluginPages().FirstOrDefault(p => string.Equals(p.Item1.Name, name, StringComparison.OrdinalIgnoreCase)); + if (altPage is null) + { + return NotFound(); } - private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin) + IPlugin plugin = altPage.Item2; + string resourcePath = altPage.Item1.EmbeddedResourcePath; + Stream? stream = plugin.GetType().Assembly.GetManifestResourceStream(resourcePath); + if (stream is null) { - return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1)); + _logger.LogError("Failed to get resource {Resource} from plugin {Plugin}", resourcePath, plugin.Name); + return NotFound(); } - private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin plugin) - { - if (plugin.Instance is not IHasWebPages hasWebPages) - { - return Enumerable.Empty<Tuple<PluginPageInfo, IPlugin>>(); - } + return File(stream, MimeTypes.GetMimeType(resourcePath)); + } - return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance)); - } + private IEnumerable<ConfigurationPageInfo> GetConfigPages(LocalPlugin plugin) + { + return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin.Instance, i.Item1)); + } - private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages() + private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages(LocalPlugin plugin) + { + if (plugin.Instance is not IHasWebPages hasWebPages) { - return _pluginManager.Plugins.SelectMany(GetPluginPages); + return Enumerable.Empty<Tuple<PluginPageInfo, IPlugin>>(); } + + return hasWebPages.GetPages().Select(i => new Tuple<PluginPageInfo, IPlugin>(i, plugin.Instance)); + } + + private IEnumerable<Tuple<PluginPageInfo, IPlugin>> GetPluginPages() + { + return _pluginManager.Plugins.SelectMany(GetPluginPages); } } diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs index aad60cf5c..497862236 100644 --- a/Jellyfin.Api/Controllers/DevicesController.cs +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -13,129 +13,128 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Devices Controller. +/// </summary> +[Authorize(Policy = Policies.RequiresElevation)] +public class DevicesController : BaseJellyfinApiController { + private readonly IDeviceManager _deviceManager; + private readonly ISessionManager _sessionManager; + /// <summary> - /// Devices Controller. + /// Initializes a new instance of the <see cref="DevicesController"/> class. /// </summary> - [Authorize(Policy = Policies.RequiresElevation)] - public class DevicesController : BaseJellyfinApiController + /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> + /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> + public DevicesController( + IDeviceManager deviceManager, + ISessionManager sessionManager) { - private readonly IDeviceManager _deviceManager; - private readonly ISessionManager _sessionManager; + _deviceManager = deviceManager; + _sessionManager = sessionManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="DevicesController"/> class. - /// </summary> - /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> - /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> - public DevicesController( - IDeviceManager deviceManager, - ISessionManager sessionManager) - { - _deviceManager = deviceManager; - _sessionManager = sessionManager; - } + /// <summary> + /// Get Devices. + /// </summary> + /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param> + /// <param name="userId">Gets or sets the user identifier.</param> + /// <response code="200">Devices retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) + { + return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false); + } - /// <summary> - /// Get Devices. - /// </summary> - /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param> - /// <param name="userId">Gets or sets the user identifier.</param> - /// <response code="200">Devices retrieved.</response> - /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) + /// <summary> + /// Get info for a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="200">Device info retrieved.</response> + /// <response code="404">Device not found.</response> + /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpGet("Info")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<DeviceInfo>> GetDeviceInfo([FromQuery, Required] string id) + { + var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false); + if (deviceInfo is null) { - return await _deviceManager.GetDevicesForUser(userId, supportsSync).ConfigureAwait(false); + return NotFound(); } - /// <summary> - /// Get info for a device. - /// </summary> - /// <param name="id">Device Id.</param> - /// <response code="200">Device info retrieved.</response> - /// <response code="404">Device not found.</response> - /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> - [HttpGet("Info")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<DeviceInfo>> GetDeviceInfo([FromQuery, Required] string id) - { - var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false); - if (deviceInfo is null) - { - return NotFound(); - } + return deviceInfo; + } - return deviceInfo; + /// <summary> + /// Get options for a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="200">Device options retrieved.</response> + /// <response code="404">Device not found.</response> + /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpGet("Options")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<DeviceOptions>> GetDeviceOptions([FromQuery, Required] string id) + { + var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false); + if (deviceInfo is null) + { + return NotFound(); } - /// <summary> - /// Get options for a device. - /// </summary> - /// <param name="id">Device Id.</param> - /// <response code="200">Device options retrieved.</response> - /// <response code="404">Device not found.</response> - /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> - [HttpGet("Options")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<DeviceOptions>> GetDeviceOptions([FromQuery, Required] string id) - { - var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false); - if (deviceInfo is null) - { - return NotFound(); - } + return deviceInfo; + } - return deviceInfo; - } + /// <summary> + /// Update device options. + /// </summary> + /// <param name="id">Device Id.</param> + /// <param name="deviceOptions">Device Options.</param> + /// <response code="204">Device options updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Options")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> UpdateDeviceOptions( + [FromQuery, Required] string id, + [FromBody, Required] DeviceOptionsDto deviceOptions) + { + await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Update device options. - /// </summary> - /// <param name="id">Device Id.</param> - /// <param name="deviceOptions">Device Options.</param> - /// <response code="204">Device options updated.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Options")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> UpdateDeviceOptions( - [FromQuery, Required] string id, - [FromBody, Required] DeviceOptionsDto deviceOptions) + /// <summary> + /// Deletes a device. + /// </summary> + /// <param name="id">Device Id.</param> + /// <response code="204">Device deleted.</response> + /// <response code="404">Device not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id) + { + var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false); + if (existingDevice is null) { - await _deviceManager.UpdateDeviceOptions(id, deviceOptions.CustomName).ConfigureAwait(false); - return NoContent(); + return NotFound(); } - /// <summary> - /// Deletes a device. - /// </summary> - /// <param name="id">Device Id.</param> - /// <response code="204">Device deleted.</response> - /// <response code="404">Device not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns> - [HttpDelete] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id) - { - var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false); - if (existingDevice is null) - { - return NotFound(); - } - - var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false); - - foreach (var session in sessions.Items) - { - await _sessionManager.Logout(session).ConfigureAwait(false); - } + var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false); - return NoContent(); + foreach (var session in sessions.Items) + { + await _sessionManager.Logout(session).ConfigureAwait(false); } + + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 67cceb4a8..49d87a362 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -14,201 +14,200 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Display Preferences Controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class DisplayPreferencesController : BaseJellyfinApiController { + private readonly IDisplayPreferencesManager _displayPreferencesManager; + private readonly ILogger<DisplayPreferencesController> _logger; + /// <summary> - /// Display Preferences Controller. + /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class DisplayPreferencesController : BaseJellyfinApiController + /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param> + /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param> + public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger) { - private readonly IDisplayPreferencesManager _displayPreferencesManager; - private readonly ILogger<DisplayPreferencesController> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="DisplayPreferencesController"/> class. - /// </summary> - /// <param name="displayPreferencesManager">Instance of <see cref="IDisplayPreferencesManager"/> interface.</param> - /// <param name="logger">Instance of <see cref="ILogger{DisplayPreferencesController}"/> interface.</param> - public DisplayPreferencesController(IDisplayPreferencesManager displayPreferencesManager, ILogger<DisplayPreferencesController> logger) + _displayPreferencesManager = displayPreferencesManager; + _logger = logger; + } + + /// <summary> + /// Get Display Preferences. + /// </summary> + /// <param name="displayPreferencesId">Display preferences id.</param> + /// <param name="userId">User id.</param> + /// <param name="client">Client.</param> + /// <response code="200">Display preferences retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns> + [HttpGet("{displayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] + public ActionResult<DisplayPreferencesDto> GetDisplayPreferences( + [FromRoute, Required] string displayPreferencesId, + [FromQuery, Required] Guid userId, + [FromQuery, Required] string client) + { + if (!Guid.TryParse(displayPreferencesId, out var itemId)) { - _displayPreferencesManager = displayPreferencesManager; - _logger = logger; + itemId = displayPreferencesId.GetMD5(); } - /// <summary> - /// Get Display Preferences. - /// </summary> - /// <param name="displayPreferencesId">Display preferences id.</param> - /// <param name="userId">User id.</param> - /// <param name="client">Client.</param> - /// <response code="200">Display preferences retrieved.</response> - /// <returns>An <see cref="OkResult"/> containing the display preferences on success, or a <see cref="NotFoundResult"/> if the display preferences could not be found.</returns> - [HttpGet("{displayPreferencesId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] - public ActionResult<DisplayPreferencesDto> GetDisplayPreferences( - [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, - [FromQuery, Required] string client) - { - if (!Guid.TryParse(displayPreferencesId, out var itemId)) - { - itemId = displayPreferencesId.GetMD5(); - } + var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); + itemPreferences.ItemId = itemId; - var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); - var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); - itemPreferences.ItemId = itemId; + var dto = new DisplayPreferencesDto + { + Client = displayPreferences.Client, + Id = displayPreferences.ItemId.ToString(), + SortBy = itemPreferences.SortBy, + SortOrder = itemPreferences.SortOrder, + IndexBy = displayPreferences.IndexBy?.ToString(), + RememberIndexing = itemPreferences.RememberIndexing, + RememberSorting = itemPreferences.RememberSorting, + ScrollDirection = displayPreferences.ScrollDirection, + ShowBackdrop = displayPreferences.ShowBackdrop, + ShowSidebar = displayPreferences.ShowSidebar + }; + + foreach (var homeSection in displayPreferences.HomeSections) + { + dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant(); + } - var dto = new DisplayPreferencesDto - { - Client = displayPreferences.Client, - Id = displayPreferences.ItemId.ToString(), - SortBy = itemPreferences.SortBy, - SortOrder = itemPreferences.SortOrder, - IndexBy = displayPreferences.IndexBy?.ToString(), - RememberIndexing = itemPreferences.RememberIndexing, - RememberSorting = itemPreferences.RememberSorting, - ScrollDirection = displayPreferences.ScrollDirection, - ShowBackdrop = displayPreferences.ShowBackdrop, - ShowSidebar = displayPreferences.ShowSidebar - }; - - foreach (var homeSection in displayPreferences.HomeSections) - { - dto.CustomPrefs["homesection" + homeSection.Order] = homeSection.Type.ToString().ToLowerInvariant(); - } + dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant(); + dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture); + dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture); + dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture); + dto.CustomPrefs["tvhome"] = displayPreferences.TvHome; + dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme; - dto.CustomPrefs["chromecastVersion"] = displayPreferences.ChromecastVersion.ToString().ToLowerInvariant(); - dto.CustomPrefs["skipForwardLength"] = displayPreferences.SkipForwardLength.ToString(CultureInfo.InvariantCulture); - dto.CustomPrefs["skipBackLength"] = displayPreferences.SkipBackwardLength.ToString(CultureInfo.InvariantCulture); - dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture); - dto.CustomPrefs["tvhome"] = displayPreferences.TvHome; - dto.CustomPrefs["dashboardTheme"] = displayPreferences.DashboardTheme; + // Load all custom display preferences + var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); + foreach (var (key, value) in customDisplayPreferences) + { + dto.CustomPrefs.TryAdd(key, value); + } - // Load all custom display preferences - var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); - foreach (var (key, value) in customDisplayPreferences) - { - dto.CustomPrefs.TryAdd(key, value); - } + // This will essentially be a noop if no changes have been made, but new prefs must be saved at least. + _displayPreferencesManager.SaveChanges(); - // This will essentially be a noop if no changes have been made, but new prefs must be saved at least. - _displayPreferencesManager.SaveChanges(); + return dto; + } - return dto; + /// <summary> + /// Update Display Preferences. + /// </summary> + /// <param name="displayPreferencesId">Display preferences id.</param> + /// <param name="userId">User Id.</param> + /// <param name="client">Client.</param> + /// <param name="displayPreferences">New Display Preferences object.</param> + /// <response code="204">Display preferences updated.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{displayPreferencesId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] + public ActionResult UpdateDisplayPreferences( + [FromRoute, Required] string displayPreferencesId, + [FromQuery, Required] Guid userId, + [FromQuery, Required] string client, + [FromBody, Required] DisplayPreferencesDto displayPreferences) + { + HomeSectionType[] defaults = + { + HomeSectionType.SmallLibraryTiles, + HomeSectionType.Resume, + HomeSectionType.ResumeAudio, + HomeSectionType.ResumeBook, + HomeSectionType.LiveTv, + HomeSectionType.NextUp, + HomeSectionType.LatestMedia, + HomeSectionType.None, + }; + + if (!Guid.TryParse(displayPreferencesId, out var itemId)) + { + itemId = displayPreferencesId.GetMD5(); } - /// <summary> - /// Update Display Preferences. - /// </summary> - /// <param name="displayPreferencesId">Display preferences id.</param> - /// <param name="userId">User Id.</param> - /// <param name="client">Client.</param> - /// <param name="displayPreferences">New Display Preferences object.</param> - /// <response code="204">Display preferences updated.</response> - /// <returns>An <see cref="NoContentResult"/> on success.</returns> - [HttpPost("{displayPreferencesId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] - public ActionResult UpdateDisplayPreferences( - [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, - [FromQuery, Required] string client, - [FromBody, Required] DisplayPreferencesDto displayPreferences) + var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null; + existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; + existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; + + existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection; + existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion) + && !string.IsNullOrEmpty(chromecastVersion) + ? Enum.Parse<ChromecastVersion>(chromecastVersion, true) + : ChromecastVersion.Stable; + displayPreferences.CustomPrefs.Remove("chromecastVersion"); + + existingDisplayPreferences.EnableNextVideoInfoOverlay = !displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay) + || string.IsNullOrEmpty(enableNextVideoInfoOverlay) + || bool.Parse(enableNextVideoInfoOverlay); + displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay"); + + existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength) + && !string.IsNullOrEmpty(skipBackLength) + ? int.Parse(skipBackLength, CultureInfo.InvariantCulture) + : 10000; + displayPreferences.CustomPrefs.Remove("skipBackLength"); + + existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength) + && !string.IsNullOrEmpty(skipForwardLength) + ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture) + : 30000; + displayPreferences.CustomPrefs.Remove("skipForwardLength"); + + existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme) + ? theme + : string.Empty; + displayPreferences.CustomPrefs.Remove("dashboardTheme"); + + existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home) + ? home + : string.Empty; + displayPreferences.CustomPrefs.Remove("tvhome"); + + existingDisplayPreferences.HomeSections.Clear(); + + foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase))) { - HomeSectionType[] defaults = - { - HomeSectionType.SmallLibraryTiles, - HomeSectionType.Resume, - HomeSectionType.ResumeAudio, - HomeSectionType.ResumeBook, - HomeSectionType.LiveTv, - HomeSectionType.NextUp, - HomeSectionType.LatestMedia, - HomeSectionType.None, - }; - - if (!Guid.TryParse(displayPreferencesId, out var itemId)) + var order = int.Parse(key.AsSpan().Slice("homesection".Length), CultureInfo.InvariantCulture); + if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type)) { - itemId = displayPreferencesId.GetMD5(); + type = order < 8 ? defaults[order] : HomeSectionType.None; } - var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); - existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null; - existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; - existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; - - existingDisplayPreferences.ScrollDirection = displayPreferences.ScrollDirection; - existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion) - && !string.IsNullOrEmpty(chromecastVersion) - ? Enum.Parse<ChromecastVersion>(chromecastVersion, true) - : ChromecastVersion.Stable; - displayPreferences.CustomPrefs.Remove("chromecastVersion"); - - existingDisplayPreferences.EnableNextVideoInfoOverlay = !displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay) - || string.IsNullOrEmpty(enableNextVideoInfoOverlay) - || bool.Parse(enableNextVideoInfoOverlay); - displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay"); - - existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength) - && !string.IsNullOrEmpty(skipBackLength) - ? int.Parse(skipBackLength, CultureInfo.InvariantCulture) - : 10000; - displayPreferences.CustomPrefs.Remove("skipBackLength"); - - existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength) - && !string.IsNullOrEmpty(skipForwardLength) - ? int.Parse(skipForwardLength, CultureInfo.InvariantCulture) - : 30000; - displayPreferences.CustomPrefs.Remove("skipForwardLength"); - - existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme) - ? theme - : string.Empty; - displayPreferences.CustomPrefs.Remove("dashboardTheme"); - - existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home) - ? home - : string.Empty; - displayPreferences.CustomPrefs.Remove("tvhome"); - - existingDisplayPreferences.HomeSections.Clear(); - - foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase))) - { - var order = int.Parse(key.AsSpan().Slice("homesection".Length), CultureInfo.InvariantCulture); - if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type)) - { - type = order < 8 ? defaults[order] : HomeSectionType.None; - } - - displayPreferences.CustomPrefs.Remove(key); - existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type }); - } + displayPreferences.CustomPrefs.Remove(key); + existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type }); + } - foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase))) + foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase))) + { + if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type)) { - if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type)) - { - _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]); - displayPreferences.CustomPrefs.Remove(key); - } + _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]); + displayPreferences.CustomPrefs.Remove(key); } + } - var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client); - itemPrefs.SortBy = displayPreferences.SortBy ?? "SortName"; - itemPrefs.SortOrder = displayPreferences.SortOrder; - itemPrefs.RememberIndexing = displayPreferences.RememberIndexing; - itemPrefs.RememberSorting = displayPreferences.RememberSorting; - itemPrefs.ItemId = itemId; + var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client); + itemPrefs.SortBy = displayPreferences.SortBy ?? "SortName"; + itemPrefs.SortOrder = displayPreferences.SortOrder; + itemPrefs.RememberIndexing = displayPreferences.RememberIndexing; + itemPrefs.RememberSorting = displayPreferences.RememberSorting; + itemPrefs.ItemId = itemId; - // Set all remaining custom preferences. - _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); - _displayPreferencesManager.SaveChanges(); + // Set all remaining custom preferences. + _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); + _displayPreferencesManager.SaveChanges(); - return NoContent(); - } + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DlnaController.cs b/Jellyfin.Api/Controllers/DlnaController.cs index 07e0590a1..415385463 100644 --- a/Jellyfin.Api/Controllers/DlnaController.cs +++ b/Jellyfin.Api/Controllers/DlnaController.cs @@ -7,127 +7,126 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Dlna Controller. +/// </summary> +[Authorize(Policy = Policies.RequiresElevation)] +public class DlnaController : BaseJellyfinApiController { + private readonly IDlnaManager _dlnaManager; + /// <summary> - /// Dlna Controller. + /// Initializes a new instance of the <see cref="DlnaController"/> class. /// </summary> - [Authorize(Policy = Policies.RequiresElevation)] - public class DlnaController : BaseJellyfinApiController + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + public DlnaController(IDlnaManager dlnaManager) { - private readonly IDlnaManager _dlnaManager; + _dlnaManager = dlnaManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="DlnaController"/> class. - /// </summary> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - public DlnaController(IDlnaManager dlnaManager) - { - _dlnaManager = dlnaManager; - } + /// <summary> + /// Get profile infos. + /// </summary> + /// <response code="200">Device profile infos returned.</response> + /// <returns>An <see cref="OkResult"/> containing the device profile infos.</returns> + [HttpGet("ProfileInfos")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<DeviceProfileInfo>> GetProfileInfos() + { + return Ok(_dlnaManager.GetProfileInfos()); + } - /// <summary> - /// Get profile infos. - /// </summary> - /// <response code="200">Device profile infos returned.</response> - /// <returns>An <see cref="OkResult"/> containing the device profile infos.</returns> - [HttpGet("ProfileInfos")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<DeviceProfileInfo>> GetProfileInfos() - { - return Ok(_dlnaManager.GetProfileInfos()); - } + /// <summary> + /// Gets the default profile. + /// </summary> + /// <response code="200">Default device profile returned.</response> + /// <returns>An <see cref="OkResult"/> containing the default profile.</returns> + [HttpGet("Profiles/Default")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<DeviceProfile> GetDefaultProfile() + { + return _dlnaManager.GetDefaultProfile(); + } - /// <summary> - /// Gets the default profile. - /// </summary> - /// <response code="200">Default device profile returned.</response> - /// <returns>An <see cref="OkResult"/> containing the default profile.</returns> - [HttpGet("Profiles/Default")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<DeviceProfile> GetDefaultProfile() + /// <summary> + /// Gets a single profile. + /// </summary> + /// <param name="profileId">Profile Id.</param> + /// <response code="200">Device profile returned.</response> + /// <response code="404">Device profile not found.</response> + /// <returns>An <see cref="OkResult"/> containing the profile on success, or a <see cref="NotFoundResult"/> if device profile not found.</returns> + [HttpGet("Profiles/{profileId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<DeviceProfile> GetProfile([FromRoute, Required] string profileId) + { + var profile = _dlnaManager.GetProfile(profileId); + if (profile is null) { - return _dlnaManager.GetDefaultProfile(); + return NotFound(); } - /// <summary> - /// Gets a single profile. - /// </summary> - /// <param name="profileId">Profile Id.</param> - /// <response code="200">Device profile returned.</response> - /// <response code="404">Device profile not found.</response> - /// <returns>An <see cref="OkResult"/> containing the profile on success, or a <see cref="NotFoundResult"/> if device profile not found.</returns> - [HttpGet("Profiles/{profileId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<DeviceProfile> GetProfile([FromRoute, Required] string profileId) - { - var profile = _dlnaManager.GetProfile(profileId); - if (profile is null) - { - return NotFound(); - } + return profile; + } - return profile; + /// <summary> + /// Deletes a profile. + /// </summary> + /// <param name="profileId">Profile id.</param> + /// <response code="204">Device profile deleted.</response> + /// <response code="404">Device profile not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns> + [HttpDelete("Profiles/{profileId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteProfile([FromRoute, Required] string profileId) + { + var existingDeviceProfile = _dlnaManager.GetProfile(profileId); + if (existingDeviceProfile is null) + { + return NotFound(); } - /// <summary> - /// Deletes a profile. - /// </summary> - /// <param name="profileId">Profile id.</param> - /// <response code="204">Device profile deleted.</response> - /// <response code="404">Device profile not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns> - [HttpDelete("Profiles/{profileId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DeleteProfile([FromRoute, Required] string profileId) - { - var existingDeviceProfile = _dlnaManager.GetProfile(profileId); - if (existingDeviceProfile is null) - { - return NotFound(); - } + _dlnaManager.DeleteProfile(profileId); + return NoContent(); + } - _dlnaManager.DeleteProfile(profileId); - return NoContent(); - } + /// <summary> + /// Creates a profile. + /// </summary> + /// <param name="deviceProfile">Device profile.</param> + /// <response code="204">Device profile created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Profiles")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile) + { + _dlnaManager.CreateProfile(deviceProfile); + return NoContent(); + } - /// <summary> - /// Creates a profile. - /// </summary> - /// <param name="deviceProfile">Device profile.</param> - /// <response code="204">Device profile created.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Profiles")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CreateProfile([FromBody] DeviceProfile deviceProfile) + /// <summary> + /// Updates a profile. + /// </summary> + /// <param name="profileId">Profile id.</param> + /// <param name="deviceProfile">Device profile.</param> + /// <response code="204">Device profile updated.</response> + /// <response code="404">Device profile not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns> + [HttpPost("Profiles/{profileId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile) + { + var existingDeviceProfile = _dlnaManager.GetProfile(profileId); + if (existingDeviceProfile is null) { - _dlnaManager.CreateProfile(deviceProfile); - return NoContent(); + return NotFound(); } - /// <summary> - /// Updates a profile. - /// </summary> - /// <param name="profileId">Profile id.</param> - /// <param name="deviceProfile">Device profile.</param> - /// <response code="204">Device profile updated.</response> - /// <response code="404">Device profile not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if profile not found.</returns> - [HttpPost("Profiles/{profileId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile) - { - var existingDeviceProfile = _dlnaManager.GetProfile(profileId); - if (existingDeviceProfile is null) - { - return NotFound(); - } - - _dlnaManager.UpdateProfile(profileId, deviceProfile); - return NoContent(); - } + _dlnaManager.UpdateProfile(profileId, deviceProfile); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 96c492b3e..95b296fae 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -14,311 +14,310 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Dlna Server Controller. +/// </summary> +[Route("Dlna")] +[DlnaEnabled] +[Authorize(Policy = Policies.AnonymousLanAccessPolicy)] +public class DlnaServerController : BaseJellyfinApiController { + private readonly IDlnaManager _dlnaManager; + private readonly IContentDirectory _contentDirectory; + private readonly IConnectionManager _connectionManager; + private readonly IMediaReceiverRegistrar _mediaReceiverRegistrar; + /// <summary> - /// Dlna Server Controller. + /// Initializes a new instance of the <see cref="DlnaServerController"/> class. /// </summary> - [Route("Dlna")] - [DlnaEnabled] - [Authorize(Policy = Policies.AnonymousLanAccessPolicy)] - public class DlnaServerController : BaseJellyfinApiController + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + public DlnaServerController(IDlnaManager dlnaManager) { - private readonly IDlnaManager _dlnaManager; - private readonly IContentDirectory _contentDirectory; - private readonly IConnectionManager _connectionManager; - private readonly IMediaReceiverRegistrar _mediaReceiverRegistrar; + _dlnaManager = dlnaManager; + _contentDirectory = DlnaEntryPoint.Current.ContentDirectory; + _connectionManager = DlnaEntryPoint.Current.ConnectionManager; + _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar; + } - /// <summary> - /// Initializes a new instance of the <see cref="DlnaServerController"/> class. - /// </summary> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - public DlnaServerController(IDlnaManager dlnaManager) - { - _dlnaManager = dlnaManager; - _contentDirectory = DlnaEntryPoint.Current.ContentDirectory; - _connectionManager = DlnaEntryPoint.Current.ConnectionManager; - _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar; - } + /// <summary> + /// Get Description Xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Description xml returned.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>An <see cref="OkResult"/> containing the description xml.</returns> + [HttpGet("{serverId}/description")] + [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public ActionResult<string> GetDescriptionXml([FromRoute, Required] string serverId) + { + var url = GetAbsoluteUri(); + var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase)); + var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress); + return Ok(xml); + } - /// <summary> - /// Get Description Xml. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Description xml returned.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>An <see cref="OkResult"/> containing the description xml.</returns> - [HttpGet("{serverId}/description")] - [HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public ActionResult<string> GetDescriptionXml([FromRoute, Required] string serverId) - { - var url = GetAbsoluteUri(); - var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase)); - var xml = _dlnaManager.GetServerDescriptionXml(Request.Headers, serverId, serverAddress); - return Ok(xml); - } + /// <summary> + /// Gets Dlna content directory xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna content directory returned.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns> + [HttpGet("{serverId}/ContentDirectory")] + [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")] + [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + public ActionResult<string> GetContentDirectory([FromRoute, Required] string serverId) + { + return Ok(_contentDirectory.GetServiceXml()); + } - /// <summary> - /// Gets Dlna content directory xml. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Dlna content directory returned.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns> - [HttpGet("{serverId}/ContentDirectory")] - [HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")] - [HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - public ActionResult<string> GetContentDirectory([FromRoute, Required] string serverId) - { - return Ok(_contentDirectory.GetServiceXml()); - } + /// <summary> + /// Gets Dlna media receiver registrar xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna media receiver registrar xml returned.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Dlna media receiver registrar xml.</returns> + [HttpGet("{serverId}/MediaReceiverRegistrar")] + [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")] + [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + public ActionResult<string> GetMediaReceiverRegistrar([FromRoute, Required] string serverId) + { + return Ok(_mediaReceiverRegistrar.GetServiceXml()); + } - /// <summary> - /// Gets Dlna media receiver registrar xml. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Dlna media receiver registrar xml returned.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Dlna media receiver registrar xml.</returns> - [HttpGet("{serverId}/MediaReceiverRegistrar")] - [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")] - [HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - public ActionResult<string> GetMediaReceiverRegistrar([FromRoute, Required] string serverId) - { - return Ok(_mediaReceiverRegistrar.GetServiceXml()); - } + /// <summary> + /// Gets Dlna media receiver registrar xml. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Dlna media receiver registrar xml returned.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Dlna media receiver registrar xml.</returns> + [HttpGet("{serverId}/ConnectionManager")] + [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")] + [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + public ActionResult<string> GetConnectionManager([FromRoute, Required] string serverId) + { + return Ok(_connectionManager.GetServiceXml()); + } - /// <summary> - /// Gets Dlna media receiver registrar xml. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Dlna media receiver registrar xml returned.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Dlna media receiver registrar xml.</returns> - [HttpGet("{serverId}/ConnectionManager")] - [HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")] - [HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - public ActionResult<string> GetConnectionManager([FromRoute, Required] string serverId) - { - return Ok(_connectionManager.GetServiceXml()); - } + /// <summary> + /// Process a content directory control request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Control response.</returns> + [HttpPost("{serverId}/ContentDirectory/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); + } - /// <summary> - /// Process a content directory control request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Control response.</returns> - [HttpPost("{serverId}/ContentDirectory/Control")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId) - { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false); - } + /// <summary> + /// Process a connection manager control request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Control response.</returns> + [HttpPost("{serverId}/ConnectionManager/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); + } - /// <summary> - /// Process a connection manager control request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Control response.</returns> - [HttpPost("{serverId}/ConnectionManager/Control")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId) - { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false); - } + /// <summary> + /// Process a media receiver registrar control request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Control response.</returns> + [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId) + { + return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); + } - /// <summary> - /// Process a media receiver registrar control request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Control response.</returns> - [HttpPost("{serverId}/MediaReceiverRegistrar/Control")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId) - { - return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false); - } + /// <summary> + /// Processes an event subscription request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Event subscription response.</returns> + [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")] + [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")] + [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId) + { + return ProcessEventRequest(_mediaReceiverRegistrar); + } - /// <summary> - /// Processes an event subscription request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Event subscription response.</returns> - [HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")] - [HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")] - [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId) - { - return ProcessEventRequest(_mediaReceiverRegistrar); - } + /// <summary> + /// Processes an event subscription request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Event subscription response.</returns> + [HttpSubscribe("{serverId}/ContentDirectory/Events")] + [HttpUnsubscribe("{serverId}/ContentDirectory/Events")] + [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId) + { + return ProcessEventRequest(_contentDirectory); + } - /// <summary> - /// Processes an event subscription request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Event subscription response.</returns> - [HttpSubscribe("{serverId}/ContentDirectory/Events")] - [HttpUnsubscribe("{serverId}/ContentDirectory/Events")] - [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId) - { - return ProcessEventRequest(_contentDirectory); - } + /// <summary> + /// Processes an event subscription request. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <response code="200">Request processed.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Event subscription response.</returns> + [HttpSubscribe("{serverId}/ConnectionManager/Events")] + [HttpUnsubscribe("{serverId}/ConnectionManager/Events")] + [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [Produces(MediaTypeNames.Text.Xml)] + [ProducesFile(MediaTypeNames.Text.Xml)] + public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId) + { + return ProcessEventRequest(_connectionManager); + } - /// <summary> - /// Processes an event subscription request. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <response code="200">Request processed.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Event subscription response.</returns> - [HttpSubscribe("{serverId}/ConnectionManager/Events")] - [HttpUnsubscribe("{serverId}/ConnectionManager/Events")] - [ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [Produces(MediaTypeNames.Text.Xml)] - [ProducesFile(MediaTypeNames.Text.Xml)] - public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId) - { - return ProcessEventRequest(_connectionManager); - } + /// <summary> + /// Gets a server icon. + /// </summary> + /// <param name="serverId">Server UUID.</param> + /// <param name="fileName">The icon filename.</param> + /// <response code="200">Request processed.</response> + /// <response code="404">Not Found.</response> + /// <response code="503">DLNA is disabled.</response> + /// <returns>Icon stream.</returns> + [HttpGet("{serverId}/icons/{fileName}")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [ProducesImageFile] + public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName) + { + return GetIconInternal(fileName); + } - /// <summary> - /// Gets a server icon. - /// </summary> - /// <param name="serverId">Server UUID.</param> - /// <param name="fileName">The icon filename.</param> - /// <response code="200">Request processed.</response> - /// <response code="404">Not Found.</response> - /// <response code="503">DLNA is disabled.</response> - /// <returns>Icon stream.</returns> - [HttpGet("{serverId}/icons/{fileName}")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [ProducesImageFile] - public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName) - { - return GetIconInternal(fileName); - } + /// <summary> + /// Gets a server icon. + /// </summary> + /// <param name="fileName">The icon filename.</param> + /// <returns>Icon stream.</returns> + /// <response code="200">Request processed.</response> + /// <response code="404">Not Found.</response> + /// <response code="503">DLNA is disabled.</response> + [HttpGet("icons/{fileName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + [ProducesImageFile] + public ActionResult GetIcon([FromRoute, Required] string fileName) + { + return GetIconInternal(fileName); + } - /// <summary> - /// Gets a server icon. - /// </summary> - /// <param name="fileName">The icon filename.</param> - /// <returns>Icon stream.</returns> - /// <response code="200">Request processed.</response> - /// <response code="404">Not Found.</response> - /// <response code="503">DLNA is disabled.</response> - [HttpGet("icons/{fileName}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - [ProducesImageFile] - public ActionResult GetIcon([FromRoute, Required] string fileName) + private ActionResult GetIconInternal(string fileName) + { + var icon = _dlnaManager.GetIcon(fileName); + if (icon is null) { - return GetIconInternal(fileName); + return NotFound(); } - private ActionResult GetIconInternal(string fileName) - { - var icon = _dlnaManager.GetIcon(fileName); - if (icon is null) - { - return NotFound(); - } + return File(icon.Stream, MimeTypes.GetMimeType(fileName)); + } - return File(icon.Stream, MimeTypes.GetMimeType(fileName)); - } + private string GetAbsoluteUri() + { + return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}"; + } - private string GetAbsoluteUri() + private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service) + { + return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers) { - return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}"; - } + InputXml = requestStream, + TargetServerUuId = id, + RequestedUrl = GetAbsoluteUri() + }); + } - private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service) + private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager) + { + var subscriptionId = Request.Headers["SID"]; + if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase)) { - return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers) - { - InputXml = requestStream, - TargetServerUuId = id, - RequestedUrl = GetAbsoluteUri() - }); - } + var notificationType = Request.Headers["NT"]; + var callback = Request.Headers["CALLBACK"]; + var timeoutString = Request.Headers["TIMEOUT"]; - private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager) - { - var subscriptionId = Request.Headers["SID"]; - if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(notificationType)) { - var notificationType = Request.Headers["NT"]; - var callback = Request.Headers["CALLBACK"]; - var timeoutString = Request.Headers["TIMEOUT"]; - - if (string.IsNullOrEmpty(notificationType)) - { - return dlnaEventManager.RenewEventSubscription( - subscriptionId, - notificationType, - timeoutString, - callback); - } - - return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback); + return dlnaEventManager.RenewEventSubscription( + subscriptionId, + notificationType, + timeoutString, + callback); } - return dlnaEventManager.CancelEventSubscription(subscriptionId); + return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback); } + + return dlnaEventManager.CancelEventSubscription(subscriptionId); } } diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index b41e23925..b68849171 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -30,2026 +30,2025 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Dynamic hls controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class DynamicHlsController : BaseJellyfinApiController { + private const string DefaultVodEncoderPreset = "veryfast"; + private const string DefaultEventEncoderPreset = "superfast"; + private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; + + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDlnaManager _dlnaManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IFileSystem _fileSystem; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ILogger<DynamicHlsController> _logger; + private readonly EncodingHelper _encodingHelper; + private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator; + private readonly DynamicHlsHelper _dynamicHlsHelper; + private readonly EncodingOptions _encodingOptions; + /// <summary> - /// Dynamic hls controller. + /// Initializes a new instance of the <see cref="DynamicHlsController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class DynamicHlsController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> + /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param> + /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + /// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param> + public DynamicHlsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDlnaManager dlnaManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IFileSystem fileSystem, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + ILogger<DynamicHlsController> logger, + DynamicHlsHelper dynamicHlsHelper, + EncodingHelper encodingHelper, + IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator) { - private const string DefaultVodEncoderPreset = "veryfast"; - private const string DefaultEventEncoderPreset = "superfast"; - private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; - - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDlnaManager _dlnaManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IFileSystem _fileSystem; - private readonly IDeviceManager _deviceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly ILogger<DynamicHlsController> _logger; - private readonly EncodingHelper _encodingHelper; - private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator; - private readonly DynamicHlsHelper _dynamicHlsHelper; - private readonly EncodingOptions _encodingOptions; - - /// <summary> - /// Initializes a new instance of the <see cref="DynamicHlsController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> - /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param> - /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - /// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param> - public DynamicHlsController( - ILibraryManager libraryManager, - IUserManager userManager, - IDlnaManager dlnaManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IFileSystem fileSystem, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - ILogger<DynamicHlsController> logger, - DynamicHlsHelper dynamicHlsHelper, - EncodingHelper encodingHelper, - IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dlnaManager = dlnaManager; - _mediaSourceManager = mediaSourceManager; - _serverConfigurationManager = serverConfigurationManager; - _mediaEncoder = mediaEncoder; - _fileSystem = fileSystem; - _deviceManager = deviceManager; - _transcodingJobHelper = transcodingJobHelper; - _logger = logger; - _dynamicHlsHelper = dynamicHlsHelper; - _encodingHelper = encodingHelper; - _dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator; - - _encodingOptions = serverConfigurationManager.GetEncodingOptions(); - } + _libraryManager = libraryManager; + _userManager = userManager; + _dlnaManager = dlnaManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _fileSystem = fileSystem; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _logger = logger; + _dynamicHlsHelper = dynamicHlsHelper; + _encodingHelper = encodingHelper; + _dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator; + + _encodingOptions = serverConfigurationManager.GetEncodingOptions(); + } - /// <summary> - /// Gets a hls live stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The audio container.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <param name="maxWidth">Optional. The max width.</param> - /// <param name="maxHeight">Optional. The max height.</param> - /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param> - /// <response code="200">Hls live stream retrieved.</response> - /// <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, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? enableSubtitlesInManifest) + /// <summary> + /// Gets a hls live stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The audio container.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="maxWidth">Optional. The max width.</param> + /// <param name="maxHeight">Optional. The max height.</param> + /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param> + /// <response code="200">Hls live stream retrieved.</response> + /// <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, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? enableSubtitlesInManifest) + { + VideoRequestDto streamingRequest = new VideoRequestDto { - VideoRequestDto streamingRequest = new VideoRequestDto - { - Id = itemId, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions, - MaxHeight = maxHeight, - MaxWidth = maxWidth, - EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true - }; - - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - // Due to CTS.Token calling ThrowIfDisposed (https://github.com/dotnet/runtime/issues/29970) we have to "cache" the token - // since it gets disposed when ffmpeg exits - var cancellationToken = cancellationTokenSource.Token; - var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - TranscodingJobType, - cancellationToken) - .ConfigureAwait(false); - - TranscodingJobDto? job = null; - var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); - - if (!System.IO.File.Exists(playlistPath)) + Id = itemId, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions, + MaxHeight = maxHeight, + MaxWidth = maxWidth, + EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true + }; + + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); + // Due to CTS.Token calling ThrowIfDisposed (https://github.com/dotnet/runtime/issues/29970) we have to "cache" the token + // since it gets disposed when ffmpeg exits + var cancellationToken = cancellationTokenSource.Token; + var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + TranscodingJobType, + cancellationToken) + .ConfigureAwait(false); + + TranscodingJobDto? job = null; + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); + + if (!System.IO.File.Exists(playlistPath)) + { + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); + await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try + if (!System.IO.File.Exists(playlistPath)) { - if (!System.IO.File.Exists(playlistPath)) + // If the playlist doesn't already exist, startup ffmpeg + try { - // If the playlist doesn't already exist, startup ffmpeg - try - { - job = await _transcodingJobHelper.StartFfMpeg( - state, - playlistPath, - GetCommandLineArguments(playlistPath, state, true, 0), - Request, - TranscodingJobType, - cancellationTokenSource) - .ConfigureAwait(false); - job.IsLiveOutput = true; - } - catch - { - state.Dispose(); - throw; - } + job = await _transcodingJobHelper.StartFfMpeg( + state, + playlistPath, + GetCommandLineArguments(playlistPath, state, true, 0), + Request, + TranscodingJobType, + cancellationTokenSource) + .ConfigureAwait(false); + job.IsLiveOutput = true; + } + catch + { + state.Dispose(); + throw; + } - minSegments = state.MinSegments; - if (minSegments > 0) - { - await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false); - } + minSegments = state.MinSegments; + if (minSegments > 0) + { + await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationToken).ConfigureAwait(false); } } - finally - { - transcodingLock.Release(); - } } - - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - - if (job is not null) + finally { - _transcodingJobHelper.OnTranscodeEndRequest(job); + transcodingLock.Release(); } - - var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); - - return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); } - /// <summary> - /// Gets a video hls playlist stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> - /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> - /// <response code="200">Video stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> - [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, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery, Required] string mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions, - [FromQuery] bool enableAdaptiveBitrateStreaming = true) - { - var streamingRequest = new HlsVideoRequestDto - { - Id = itemId, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions, - EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming - }; + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + if (job is not null) + { + _transcodingJobHelper.OnTranscodeEndRequest(job); } - /// <summary> - /// Gets an audio hls playlist stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> - /// <response code="200">Audio stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> - [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] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery, Required] string mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? maxStreamingBitrate, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions, - [FromQuery] bool enableAdaptiveBitrateStreaming = true) - { - var streamingRequest = new HlsAudioRequestDto - { - Id = itemId, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate ?? maxStreamingBitrate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions, - EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming - }; + var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); - return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); - } + return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); + } - /// <summary> - /// Gets a video stream using HTTP live streaming. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> - /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Video stream returned.</response> - /// <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] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + /// <summary> + /// Gets a video hls playlist stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> + [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, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery, Required] string mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAdaptiveBitrateStreaming = true) + { + var streamingRequest = new HlsVideoRequestDto { - using var cancellationTokenSource = new CancellationTokenSource(); - var streamingRequest = new VideoRequestDto - { - Id = itemId, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions - }; - - return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) - .ConfigureAwait(false); - } + Id = itemId, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions, + EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming + }; + + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + } - /// <summary> - /// Gets an audio stream using HTTP live streaming. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Audio stream returned.</response> - /// <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] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? maxStreamingBitrate, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + /// <summary> + /// Gets an audio hls playlist stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> + /// <response code="200">Audio stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> + [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] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery, Required] string mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAdaptiveBitrateStreaming = true) + { + var streamingRequest = new HlsAudioRequestDto { - using var cancellationTokenSource = new CancellationTokenSource(); - var streamingRequest = new StreamingRequestDto - { - Id = itemId, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate ?? maxStreamingBitrate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions - }; + Id = itemId, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions, + EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming + }; + + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + } - return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) - .ConfigureAwait(false); - } + /// <summary> + /// Gets a video stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <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] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + using var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new VideoRequestDto + { + Id = itemId, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions + }; + + return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) + .ConfigureAwait(false); + } - /// <summary> - /// Gets a video stream using HTTP live streaming. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="playlistId">The playlist id.</param> - /// <param name="segmentId">The segment id.</param> - /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> - /// <param name="runtimeTicks">The position of the requested segment in ticks.</param> - /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The desired segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> - /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Video stream returned.</response> - /// <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, - [FromRoute, Required] string playlistId, - [FromRoute, Required] int segmentId, - [FromRoute, Required] string container, - [FromQuery, Required] long runtimeTicks, - [FromQuery, Required] long actualSegmentLengthTicks, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + /// <summary> + /// Gets an audio stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Audio stream returned.</response> + /// <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] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + using var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new StreamingRequestDto { - var streamingRequest = new VideoRequestDto - { - Id = itemId, - CurrentRuntimeTicks = runtimeTicks, - ActualSegmentLengthTicks = actualSegmentLengthTicks, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions - }; + Id = itemId, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions + }; + + return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) + .ConfigureAwait(false); + } - return await GetDynamicSegment(streamingRequest, segmentId) - .ConfigureAwait(false); - } + /// <summary> + /// Gets a video stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <param name="segmentId">The segment id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="runtimeTicks">The position of the requested segment in ticks.</param> + /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The desired segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <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, + [FromRoute, Required] string playlistId, + [FromRoute, Required] int segmentId, + [FromRoute, Required] string container, + [FromQuery, Required] long runtimeTicks, + [FromQuery, Required] long actualSegmentLengthTicks, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var streamingRequest = new VideoRequestDto + { + Id = itemId, + CurrentRuntimeTicks = runtimeTicks, + ActualSegmentLengthTicks = actualSegmentLengthTicks, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions + }; + + return await GetDynamicSegment(streamingRequest, segmentId) + .ConfigureAwait(false); + } - /// <summary> - /// Gets a video stream using HTTP live streaming. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="playlistId">The playlist id.</param> - /// <param name="segmentId">The segment id.</param> - /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> - /// <param name="runtimeTicks">The position of the requested segment in ticks.</param> - /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Video stream returned.</response> - /// <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, - [FromRoute, Required] string playlistId, - [FromRoute, Required] int segmentId, - [FromRoute, Required] string container, - [FromQuery, Required] long runtimeTicks, - [FromQuery, Required] long actualSegmentLengthTicks, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? maxStreamingBitrate, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + /// <summary> + /// Gets a video stream using HTTP live streaming. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <param name="segmentId">The segment id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="runtimeTicks">The position of the requested segment in ticks.</param> + /// <param name="actualSegmentLengthTicks">The length of the requested segment in ticks.</param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <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, + [FromRoute, Required] string playlistId, + [FromRoute, Required] int segmentId, + [FromRoute, Required] string container, + [FromQuery, Required] long runtimeTicks, + [FromQuery, Required] long actualSegmentLengthTicks, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var streamingRequest = new StreamingRequestDto { - var streamingRequest = new StreamingRequestDto - { - Id = itemId, - Container = container, - CurrentRuntimeTicks = runtimeTicks, - ActualSegmentLengthTicks = actualSegmentLengthTicks, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate ?? maxStreamingBitrate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions - }; + Id = itemId, + Container = container, + CurrentRuntimeTicks = runtimeTicks, + ActualSegmentLengthTicks = actualSegmentLengthTicks, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions + }; + + return await GetDynamicSegment(streamingRequest, segmentId) + .ConfigureAwait(false); + } - return await GetDynamicSegment(streamingRequest, segmentId) - .ConfigureAwait(false); - } + private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, CancellationTokenSource cancellationTokenSource) + { + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + TranscodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); + + var request = new CreateMainPlaylistRequest( + state.MediaPath, + state.SegmentLength * 1000, + state.RunTimeTicks ?? 0, + state.Request.SegmentContainer ?? string.Empty, + "hls1/main/", + Request.QueryString.ToString(), + EncodingHelper.IsCopyCodec(state.OutputVideoCodec)); + var playlist = _dynamicHlsPlaylistGenerator.CreateMainPlaylist(request); + + return new FileContentResult(Encoding.UTF8.GetBytes(playlist), MimeTypes.GetMimeType("playlist.m3u8")); + } - private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, CancellationTokenSource cancellationTokenSource) + private async Task<ActionResult> GetDynamicSegment(StreamingRequestDto streamingRequest, int segmentId) + { + if ((streamingRequest.StartTimeTicks ?? 0) > 0) { - using var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - TranscodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - var request = new CreateMainPlaylistRequest( - state.MediaPath, - state.SegmentLength * 1000, - state.RunTimeTicks ?? 0, - state.Request.SegmentContainer ?? string.Empty, - "hls1/main/", - Request.QueryString.ToString(), - EncodingHelper.IsCopyCodec(state.OutputVideoCodec)); - var playlist = _dynamicHlsPlaylistGenerator.CreateMainPlaylist(request); - - return new FileContentResult(Encoding.UTF8.GetBytes(playlist), MimeTypes.GetMimeType("playlist.m3u8")); + throw new ArgumentException("StartTimeTicks is not allowed."); } - private async Task<ActionResult> GetDynamicSegment(StreamingRequestDto streamingRequest, int segmentId) - { - if ((streamingRequest.StartTimeTicks ?? 0) > 0) - { - throw new ArgumentException("StartTimeTicks is not allowed."); - } + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - var cancellationToken = cancellationTokenSource.Token; + var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + TranscodingJobType, + cancellationToken) + .ConfigureAwait(false); - var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - TranscodingJobType, - cancellationToken) - .ConfigureAwait(false); + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); - var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); + var segmentPath = GetSegmentPath(state, playlistPath, segmentId); - var segmentPath = GetSegmentPath(state, playlistPath, segmentId); + var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + TranscodingJobDto? job; + + if (System.IO.File.Exists(segmentPath)) + { + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + } - TranscodingJobDto? job; + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); + await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); + var released = false; + var startTranscoding = false; + try + { if (System.IO.File.Exists(segmentPath)) { job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - _logger.LogDebug("returning {0} [it exists, try 1]", segmentPath); + transcodingLock.Release(); + released = true; + _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - var released = false; - var startTranscoding = false; - - try + else { - if (System.IO.File.Exists(segmentPath)) + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; + + if (segmentId == -1) { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - transcodingLock.Release(); - released = true; - _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); + startTranscoding = true; + segmentId = 0; } - else + else if (currentTranscodingIndex is null) { - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - - if (segmentId == -1) - { - _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); - startTranscoding = true; - segmentId = 0; - } - else if (currentTranscodingIndex is null) - { - _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); - startTranscoding = true; - } - else if (segmentId < currentTranscodingIndex.Value) - { - _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); - startTranscoding = true; - } - else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) - { - _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); - startTranscoding = true; - } + _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); + startTranscoding = true; + } + else if (segmentId < currentTranscodingIndex.Value) + { + _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); + startTranscoding = true; + } + else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) + { + _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); + startTranscoding = true; + } - if (startTranscoding) + if (startTranscoding) + { + // If the playlist doesn't already exist, startup ffmpeg + try { - // If the playlist doesn't already exist, startup ffmpeg - try - { - await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) - .ConfigureAwait(false); + await _transcodingJobHelper.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) + .ConfigureAwait(false); - if (currentTranscodingIndex.HasValue) - { - DeleteLastFile(playlistPath, segmentExtension, 0); - } - - streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; - - state.WaitForPath = segmentPath; - job = await _transcodingJobHelper.StartFfMpeg( - state, - playlistPath, - GetCommandLineArguments(playlistPath, state, false, segmentId), - Request, - TranscodingJobType, - cancellationTokenSource).ConfigureAwait(false); - } - catch + if (currentTranscodingIndex.HasValue) { - state.Dispose(); - throw; + DeleteLastFile(playlistPath, segmentExtension, 0); } - // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); + streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; + + state.WaitForPath = segmentPath; + job = await _transcodingJobHelper.StartFfMpeg( + state, + playlistPath, + GetCommandLineArguments(playlistPath, state, false, segmentId), + Request, + TranscodingJobType, + cancellationTokenSource).ConfigureAwait(false); } - else + catch { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - if (job?.TranscodingThrottler is not null) - { - await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); - } + state.Dispose(); + throw; } + + // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); } - } - finally - { - if (!released) + else { - transcodingLock.Release(); + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + if (job?.TranscodingThrottler is not null) + { + await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); + } } } - - _logger.LogDebug("returning {0} [general case]", segmentPath); - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - - private static double[] GetSegmentLengths(StreamState state) - => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength); - - internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength) + finally { - var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks; - var wholeSegments = runtimeTicks / segmentLengthTicks; - var remainingTicks = runtimeTicks % segmentLengthTicks; - - var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1); - var segments = new double[segmentsLen]; - for (int i = 0; i < wholeSegments; i++) + if (!released) { - segments[i] = segmentlength; + transcodingLock.Release(); } + } - if (remainingTicks != 0) - { - segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds; - } + _logger.LogDebug("returning {0} [general case]", segmentPath); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); + } + + private static double[] GetSegmentLengths(StreamState state) + => GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength); - return segments; + internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength) + { + var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks; + var wholeSegments = runtimeTicks / segmentLengthTicks; + var remainingTicks = runtimeTicks % segmentLengthTicks; + + var segmentsLen = wholeSegments + (remainingTicks == 0 ? 0 : 1); + var segments = new double[segmentsLen]; + for (int i = 0; i < wholeSegments; i++) + { + segments[i] = segmentlength; } - private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber) + if (remainingTicks != 0) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); + segments[^1] = TimeSpan.FromTicks(remainingTicks).TotalSeconds; + } - if (state.BaseRequest.BreakOnNonKeyFrames) - { - // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe - // breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable - // to produce a missing part of video stream before first keyframe is encountered, which may lead to - // awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js - _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request"); - state.BaseRequest.BreakOnNonKeyFrames = false; - } + return segments; + } - var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; + private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber) + { + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); - var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); - var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); - var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - var outputTsArg = outputPrefix + "%d" + outputExtension; + if (state.BaseRequest.BreakOnNonKeyFrames) + { + // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe + // breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable + // to produce a missing part of video stream before first keyframe is encountered, which may lead to + // awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js + _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request"); + state.BaseRequest.BreakOnNonKeyFrames = false; + } - var segmentFormat = string.Empty; - var segmentContainer = outputExtension.TrimStart('.'); - var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer); + var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; - if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase)) - { - segmentFormat = "mpegts"; - } - else if (string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase)) - { - var outputFmp4HeaderArg = OperatingSystem.IsWindows() switch - { - // on Windows, the path of fmp4 header file needs to be configured - true => " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"", - // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder - false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"" - }; + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); + var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); + var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + var outputTsArg = outputPrefix + "%d" + outputExtension; - segmentFormat = "fmp4" + outputFmp4HeaderArg; - } - else + var segmentFormat = string.Empty; + var segmentContainer = outputExtension.TrimStart('.'); + var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer); + + if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase)) + { + segmentFormat = "mpegts"; + } + else if (string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var outputFmp4HeaderArg = OperatingSystem.IsWindows() switch { - _logger.LogError("Invalid HLS segment container: {SegmentContainer}, default to mpegts", segmentContainer); - segmentFormat = "mpegts"; - } + // on Windows, the path of fmp4 header file needs to be configured + true => " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"", + // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder + false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"" + }; - var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 - ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) - : "128"; + segmentFormat = "fmp4" + outputFmp4HeaderArg; + } + else + { + _logger.LogError("Invalid HLS segment container: {SegmentContainer}, default to mpegts", segmentContainer); + segmentFormat = "mpegts"; + } - var baseUrlParam = string.Empty; - if (isEventPlaylist) - { - baseUrlParam = string.Format( - CultureInfo.InvariantCulture, - " -hls_base_url \"hls/{0}/\"", - Path.GetFileNameWithoutExtension(outputPath)); - } + var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 + ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) + : "128"; - return string.Format( + var baseUrlParam = string.Empty; + if (isEventPlaylist) + { + baseUrlParam = string.Format( CultureInfo.InvariantCulture, - "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{12}\" -hls_playlist_type {11} -hls_list_size 0 -y \"{13}\"", - inputModifier, - _encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer), - threads, - mapArgs, - GetVideoArguments(state, startNumber, isEventPlaylist), - GetAudioArguments(state), - maxMuxingQueueSize, - state.SegmentLength.ToString(CultureInfo.InvariantCulture), - segmentFormat, - startNumber.ToString(CultureInfo.InvariantCulture), - baseUrlParam, - isEventPlaylist ? "event" : "vod", - outputTsArg, - outputPath).Trim(); + " -hls_base_url \"hls/{0}/\"", + Path.GetFileNameWithoutExtension(outputPath)); } - /// <summary> - /// Gets the audio arguments for transcoding. - /// </summary> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <returns>The command line arguments for audio transcoding.</returns> - private string GetAudioArguments(StreamState state) + return string.Format( + CultureInfo.InvariantCulture, + "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{12}\" -hls_playlist_type {11} -hls_list_size 0 -y \"{13}\"", + inputModifier, + _encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer), + threads, + mapArgs, + GetVideoArguments(state, startNumber, isEventPlaylist), + GetAudioArguments(state), + maxMuxingQueueSize, + state.SegmentLength.ToString(CultureInfo.InvariantCulture), + segmentFormat, + startNumber.ToString(CultureInfo.InvariantCulture), + baseUrlParam, + isEventPlaylist ? "event" : "vod", + outputTsArg, + outputPath).Trim(); + } + + /// <summary> + /// Gets the audio arguments for transcoding. + /// </summary> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <returns>The command line arguments for audio transcoding.</returns> + private string GetAudioArguments(StreamState state) + { + if (state.AudioStream is null) { - if (state.AudioStream is null) - { - return string.Empty; - } + return string.Empty; + } - var audioCodec = _encodingHelper.GetAudioEncoder(state); + var audioCodec = _encodingHelper.GetAudioEncoder(state); - if (!state.IsOutputVideo) + if (!state.IsOutputVideo) + { + if (EncodingHelper.IsCopyCodec(audioCodec)) { - if (EncodingHelper.IsCopyCodec(audioCodec)) - { - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); - - return "-acodec copy -strict -2" + bitStreamArgs; - } - - var audioTranscodeParams = string.Empty; - - audioTranscodeParams += "-acodec " + audioCodec; + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); - if (state.OutputAudioBitrate.HasValue) - { - audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); - } + return "-acodec copy -strict -2" + bitStreamArgs; + } - if (state.OutputAudioChannels.HasValue) - { - audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); - } + var audioTranscodeParams = string.Empty; - if (state.OutputAudioSampleRate.HasValue) - { - audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); - } + audioTranscodeParams += "-acodec " + audioCodec; - audioTranscodeParams += " -vn"; - return audioTranscodeParams; + if (state.OutputAudioBitrate.HasValue) + { + audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); } - // dts, flac, opus and truehd are experimental in mp4 muxer - var strictArgs = string.Empty; - - if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase)) + if (state.OutputAudioChannels.HasValue) { - strictArgs = " -strict -2"; + audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); } - if (EncodingHelper.IsCopyCodec(audioCodec)) + if (state.OutputAudioSampleRate.HasValue) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); - var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs; + audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } - if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) - { - return copyArgs + " -copypriorss:a:0 0"; - } + audioTranscodeParams += " -vn"; + return audioTranscodeParams; + } - return copyArgs; - } + // dts, flac, opus and truehd are experimental in mp4 muxer + var strictArgs = string.Empty; - var args = "-codec:a:0 " + audioCodec + strictArgs; + if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase)) + { + strictArgs = " -strict -2"; + } - var channels = state.OutputAudioChannels; + if (EncodingHelper.IsCopyCodec(audioCodec)) + { + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs; - if (channels.HasValue - && (channels.Value != 2 - || (state.AudioStream is not null - && state.AudioStream.Channels.HasValue - && state.AudioStream.Channels.Value > 5 - && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))) + if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) { - args += " -ac " + channels.Value; + return copyArgs + " -copypriorss:a:0 0"; } - var bitrate = state.OutputAudioBitrate; + return copyArgs; + } - if (bitrate.HasValue) - { - args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); - } + var args = "-codec:a:0 " + audioCodec + strictArgs; - if (state.OutputAudioSampleRate.HasValue) - { - args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); - } + var channels = state.OutputAudioChannels; + + if (channels.HasValue + && (channels.Value != 2 + || (state.AudioStream is not null + && state.AudioStream.Channels.HasValue + && state.AudioStream.Channels.Value > 5 + && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))) + { + args += " -ac " + channels.Value; + } - args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); + var bitrate = state.OutputAudioBitrate; - return args; + if (bitrate.HasValue) + { + args += " -ab " + bitrate.Value.ToString(CultureInfo.InvariantCulture); } - /// <summary> - /// Gets the video arguments for transcoding. - /// </summary> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <param name="startNumber">The first number in the hls sequence.</param> - /// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param> - /// <returns>The command line arguments for video transcoding.</returns> - private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist) + if (state.OutputAudioSampleRate.HasValue) { - if (state.VideoStream is null) - { - return string.Empty; - } + args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } - if (!state.IsOutputVideo) - { - return string.Empty; - } + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); - var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + return args; + } - var args = "-codec:v:0 " + codec; + /// <summary> + /// Gets the video arguments for transcoding. + /// </summary> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <param name="startNumber">The first number in the hls sequence.</param> + /// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param> + /// <returns>The command line arguments for video transcoding.</returns> + private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist) + { + if (state.VideoStream is null) + { + return string.Empty; + } - if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - if (EncodingHelper.IsCopyCodec(codec) - && (string.Equals(state.VideoStream.VideoRangeType, "DOVI", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.VideoStream.CodecTag, "dovi", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.VideoStream.CodecTag, "dvh1", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.VideoStream.CodecTag, "dvhe", StringComparison.OrdinalIgnoreCase))) - { - // Prefer dvh1 to dvhe - args += " -tag:v:0 dvh1 -strict -2"; - } - else - { - // Prefer hvc1 to hev1 - args += " -tag:v:0 hvc1"; - } - } + if (!state.IsOutputVideo) + { + return string.Empty; + } - // if (state.EnableMpegtsM2TsMode) - // { - // args += " -mpegts_m2ts_mode 1"; - // } + var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - // See if we can save come cpu cycles by avoiding encoding. - if (EncodingHelper.IsCopyCodec(codec)) - { - // If h264_mp4toannexb is ever added, do not use it for live tv. - if (state.VideoStream is not null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) - { - string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); - if (!string.IsNullOrEmpty(bitStreamArgs)) - { - args += " " + bitStreamArgs; - } - } + var args = "-codec:v:0 " + codec; - args += " -start_at_zero"; + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + if (EncodingHelper.IsCopyCodec(codec) + && (string.Equals(state.VideoStream.VideoRangeType, "DOVI", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.VideoStream.CodecTag, "dovi", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.VideoStream.CodecTag, "dvh1", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.VideoStream.CodecTag, "dvhe", StringComparison.OrdinalIgnoreCase))) + { + // Prefer dvh1 to dvhe + args += " -tag:v:0 dvh1 -strict -2"; } else { - args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset); + // Prefer hvc1 to hev1 + args += " -tag:v:0 hvc1"; + } + } - // Set the key frame params for video encoding to match the hls segment time. - args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber); + // if (state.EnableMpegtsM2TsMode) + // { + // args += " -mpegts_m2ts_mode 1"; + // } - // Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. - if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) + // See if we can save come cpu cycles by avoiding encoding. + if (EncodingHelper.IsCopyCodec(codec)) + { + // If h264_mp4toannexb is ever added, do not use it for live tv. + if (state.VideoStream is not null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) + { + string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); + if (!string.IsNullOrEmpty(bitStreamArgs)) { - args += " -bf 0"; + args += " " + bitStreamArgs; } + } - // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; - - // video processing filters. - args += _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec); + args += " -start_at_zero"; + } + else + { + args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset); - // -start_at_zero is necessary to use with -ss when seeking, - // otherwise the target position cannot be determined. - if (state.SubtitleStream is not null) - { - // Disable start_at_zero for external graphical subs - if (!(state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)) - { - args += " -start_at_zero"; - } - } - } + // Set the key frame params for video encoding to match the hls segment time. + args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber); - // TODO why was this not enabled for VOD? - if (isEventPlaylist) + // Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. + if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) { - args += " -flags -global_header"; + args += " -bf 0"; } - if (!string.IsNullOrEmpty(state.OutputVideoSync)) - { - args += " -vsync " + state.OutputVideoSync; - } + // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; - args += _encodingHelper.GetOutputFFlags(state); + // video processing filters. + args += _encodingHelper.GetVideoProcessingFilterParam(state, _encodingOptions, codec); - return args; + // -start_at_zero is necessary to use with -ss when seeking, + // otherwise the target position cannot be determined. + if (state.SubtitleStream is not null) + { + // Disable start_at_zero for external graphical subs + if (!(state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream)) + { + args += " -start_at_zero"; + } + } } - private string GetSegmentPath(StreamState state, string playlist, int index) + // TODO why was this not enabled for VOD? + if (isEventPlaylist) { - var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist)); - var filename = Path.GetFileNameWithoutExtension(playlist); + args += " -flags -global_header"; + } - return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer)); + if (!string.IsNullOrEmpty(state.OutputVideoSync)) + { + args += " -vsync " + state.OutputVideoSync; } - private async Task<ActionResult> GetSegmentResult( - StreamState state, - string playlistPath, - string segmentPath, - string segmentExtension, - int segmentIndex, - TranscodingJobDto? transcodingJob, - CancellationToken cancellationToken) + args += _encodingHelper.GetOutputFFlags(state); + + return args; + } + + private string GetSegmentPath(StreamState state, string playlist, int index) + { + var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException($"Provided path ({playlist}) is not valid.", nameof(playlist)); + var filename = Path.GetFileNameWithoutExtension(playlist); + + return Path.Combine(folder, filename + index.ToString(CultureInfo.InvariantCulture) + EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer)); + } + + private async Task<ActionResult> GetSegmentResult( + StreamState state, + string playlistPath, + string segmentPath, + string segmentExtension, + int segmentIndex, + TranscodingJobDto? transcodingJob, + CancellationToken cancellationToken) + { + var segmentExists = System.IO.File.Exists(segmentPath); + if (segmentExists) { - var segmentExists = System.IO.File.Exists(segmentPath); - if (segmentExists) + if (transcodingJob is not null && transcodingJob.HasExited) { - if (transcodingJob is not null && transcodingJob.HasExited) - { - // Transcoding job is over, so assume all existing files are ready - _logger.LogDebug("serving up {0} as transcode is over", segmentPath); - return GetSegmentResult(state, segmentPath, transcodingJob); - } + // Transcoding job is over, so assume all existing files are ready + _logger.LogDebug("serving up {0} as transcode is over", segmentPath); + return GetSegmentResult(state, segmentPath, transcodingJob); + } - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready - if (segmentIndex < currentTranscodingIndex) - { - _logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex); - return GetSegmentResult(state, segmentPath, transcodingJob); - } + // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready + if (segmentIndex < currentTranscodingIndex) + { + _logger.LogDebug("serving up {0} as transcode index {1} is past requested point {2}", segmentPath, currentTranscodingIndex, segmentIndex); + return GetSegmentResult(state, segmentPath, transcodingJob); } + } - var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1); - if (transcodingJob is not null) + var nextSegmentPath = GetSegmentPath(state, playlistPath, segmentIndex + 1); + if (transcodingJob is not null) + { + while (!cancellationToken.IsCancellationRequested && !transcodingJob.HasExited) { - while (!cancellationToken.IsCancellationRequested && !transcodingJob.HasExited) + // To be considered ready, the segment file has to exist AND + // either the transcoding job should be done or next segment should also exist + if (segmentExists) { - // To be considered ready, the segment file has to exist AND - // either the transcoding job should be done or next segment should also exist - if (segmentExists) + if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath)) { - if (transcodingJob.HasExited || System.IO.File.Exists(nextSegmentPath)) - { - _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath); - return GetSegmentResult(state, segmentPath, transcodingJob); - } + _logger.LogDebug("Serving up {SegmentPath} as it deemed ready", segmentPath); + return GetSegmentResult(state, segmentPath, transcodingJob); } - else - { - segmentExists = System.IO.File.Exists(segmentPath); - if (segmentExists) - { - continue; // avoid unnecessary waiting if segment just became available - } - } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - - if (!System.IO.File.Exists(segmentPath)) - { - _logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath); } else { - _logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath); + segmentExists = System.IO.File.Exists(segmentPath); + if (segmentExists) + { + continue; // avoid unnecessary waiting if segment just became available + } } - cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + if (!System.IO.File.Exists(segmentPath)) + { + _logger.LogWarning("cannot serve {0} as transcoding quit before we got there", segmentPath); } else { - _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath); + _logger.LogDebug("serving {0} as it's on disk and transcoding stopped", segmentPath); } - return GetSegmentResult(state, segmentPath, transcodingJob); + cancellationToken.ThrowIfCancellationRequested(); } - - private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob) + else { - var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks; - - Response.OnCompleted(() => - { - _logger.LogDebug("Finished serving {SegmentPath}", segmentPath); - if (transcodingJob is not null) - { - transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); - _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); - } + _logger.LogWarning("cannot serve {0} as it doesn't exist and no transcode is running", segmentPath); + } - return Task.CompletedTask; - }); + return GetSegmentResult(state, segmentPath, transcodingJob); + } - return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)); - } + private ActionResult GetSegmentResult(StreamState state, string segmentPath, TranscodingJobDto? transcodingJob) + { + var segmentEndingPositionTicks = state.Request.CurrentRuntimeTicks + state.Request.ActualSegmentLengthTicks; - private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) + Response.OnCompleted(() => { - var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType); - - if (job is null || job.HasExited) + _logger.LogDebug("Finished serving {SegmentPath}", segmentPath); + if (transcodingJob is not null) { - return null; + transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); + _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); } - var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem); + return Task.CompletedTask; + }); - if (file is null) - { - return null; - } - - var playlistFilename = Path.GetFileNameWithoutExtension(playlist); + return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)); + } - var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length); + private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) + { + var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType); - return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture); + if (job is null || job.HasExited) + { + return null; } - private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem) + var file = GetLastTranscodingFile(playlist, segmentExtension, _fileSystem); + + if (file is null) { - var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException("Path can't be a root directory.", nameof(playlist)); + return null; + } - var filePrefix = Path.GetFileNameWithoutExtension(playlist); + var playlistFilename = Path.GetFileNameWithoutExtension(playlist); - try - { - return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false) - .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(fileSystem.GetLastWriteTimeUtc) - .FirstOrDefault(); - } - catch (IOException) - { - return null; - } - } + var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length); + + return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture); + } + + private static FileSystemMetadata? GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem) + { + var folder = Path.GetDirectoryName(playlist) ?? throw new ArgumentException("Path can't be a root directory.", nameof(playlist)); - private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + var filePrefix = Path.GetFileNameWithoutExtension(playlist); + + try + { + return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false) + .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(fileSystem.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + catch (IOException) { - var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); + return null; + } + } - if (file is not null) - { - DeleteFile(file.FullName, retryCount); - } + private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + { + var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); + + if (file is not null) + { + DeleteFile(file.FullName, retryCount); } + } - private void DeleteFile(string path, int retryCount) + private void DeleteFile(string path, int retryCount) + { + if (retryCount >= 5) { - if (retryCount >= 5) - { - return; - } + return; + } - _logger.LogDebug("Deleting partial HLS file {Path}", path); + _logger.LogDebug("Deleting partial HLS file {Path}", path); - try - { - _fileSystem.DeleteFile(path); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + try + { + _fileSystem.DeleteFile(path); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - var task = Task.Delay(100); - task.Wait(); - DeleteFile(path, retryCount + 1); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - } + var task = Task.Delay(100); + task.Wait(); + DeleteFile(path, retryCount + 1); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); } } } diff --git a/Jellyfin.Api/Controllers/EnvironmentController.cs b/Jellyfin.Api/Controllers/EnvironmentController.cs index 6c78a7987..8c9ee1a19 100644 --- a/Jellyfin.Api/Controllers/EnvironmentController.cs +++ b/Jellyfin.Api/Controllers/EnvironmentController.cs @@ -12,186 +12,185 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Environment Controller. +/// </summary> +[Authorize(Policy = Policies.FirstTimeSetupOrElevated)] +public class EnvironmentController : BaseJellyfinApiController { + private const char UncSeparator = '\\'; + private const string UncStartPrefix = @"\\"; + + private readonly IFileSystem _fileSystem; + private readonly ILogger<EnvironmentController> _logger; + /// <summary> - /// Environment Controller. + /// Initializes a new instance of the <see cref="EnvironmentController"/> class. /// </summary> - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - public class EnvironmentController : BaseJellyfinApiController + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param> + public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger) { - private const char UncSeparator = '\\'; - private const string UncStartPrefix = @"\\"; - - private readonly IFileSystem _fileSystem; - private readonly ILogger<EnvironmentController> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="EnvironmentController"/> class. - /// </summary> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param> - public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger) - { - _fileSystem = fileSystem; - _logger = logger; - } + _fileSystem = fileSystem; + _logger = logger; + } - /// <summary> - /// Gets the contents of a given directory in the file system. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param> - /// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param> - /// <response code="200">Directory contents returned.</response> - /// <returns>Directory contents.</returns> - [HttpGet("DirectoryContents")] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<FileSystemEntryInfo> GetDirectoryContents( - [FromQuery, Required] string path, - [FromQuery] bool includeFiles = false, - [FromQuery] bool includeDirectories = false) + /// <summary> + /// Gets the contents of a given directory in the file system. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param> + /// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param> + /// <response code="200">Directory contents returned.</response> + /// <returns>Directory contents.</returns> + [HttpGet("DirectoryContents")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<FileSystemEntryInfo> GetDirectoryContents( + [FromQuery, Required] string path, + [FromQuery] bool includeFiles = false, + [FromQuery] bool includeDirectories = false) + { + if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase) + && path.LastIndexOf(UncSeparator) == 1) { - if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase) - && path.LastIndexOf(UncSeparator) == 1) - { - return Array.Empty<FileSystemEntryInfo>(); - } + return Array.Empty<FileSystemEntryInfo>(); + } - var entries = - _fileSystem.GetFileSystemEntries(path) - .Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles)) - .OrderBy(i => i.FullName); + var entries = + _fileSystem.GetFileSystemEntries(path) + .Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles)) + .OrderBy(i => i.FullName); - return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File)); - } + return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File)); + } - /// <summary> - /// Validates path. - /// </summary> - /// <param name="validatePathDto">Validate request object.</param> - /// <response code="204">Path validated.</response> - /// <response code="404">Path not found.</response> - /// <returns>Validation status.</returns> - [HttpPost("ValidatePath")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto) + /// <summary> + /// Validates path. + /// </summary> + /// <param name="validatePathDto">Validate request object.</param> + /// <response code="204">Path validated.</response> + /// <response code="404">Path not found.</response> + /// <returns>Validation status.</returns> + [HttpPost("ValidatePath")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto) + { + if (validatePathDto.IsFile.HasValue) { - if (validatePathDto.IsFile.HasValue) + if (validatePathDto.IsFile.Value) { - if (validatePathDto.IsFile.Value) + if (!System.IO.File.Exists(validatePathDto.Path)) { - if (!System.IO.File.Exists(validatePathDto.Path)) - { - return NotFound(); - } - } - else - { - if (!Directory.Exists(validatePathDto.Path)) - { - return NotFound(); - } + return NotFound(); } } else { - if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path)) + if (!Directory.Exists(validatePathDto.Path)) { return NotFound(); } + } + } + else + { + if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path)) + { + return NotFound(); + } - if (validatePathDto.ValidateWritable) + if (validatePathDto.ValidateWritable) + { + if (validatePathDto.Path is null) { - if (validatePathDto.Path is null) - { - throw new ResourceNotFoundException(nameof(validatePathDto.Path)); - } + throw new ResourceNotFoundException(nameof(validatePathDto.Path)); + } - var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString()); - try - { - System.IO.File.WriteAllText(file, string.Empty); - } - finally + var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString()); + try + { + System.IO.File.WriteAllText(file, string.Empty); + } + finally + { + if (System.IO.File.Exists(file)) { - if (System.IO.File.Exists(file)) - { - System.IO.File.Delete(file); - } + System.IO.File.Delete(file); } } } - - return NoContent(); } - /// <summary> - /// Gets network paths. - /// </summary> - /// <response code="200">Empty array returned.</response> - /// <returns>List of entries.</returns> - [Obsolete("This endpoint is obsolete.")] - [HttpGet("NetworkShares")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares() - { - _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares"); - return Array.Empty<FileSystemEntryInfo>(); - } + return NoContent(); + } - /// <summary> - /// Gets available drives from the server's file system. - /// </summary> - /// <response code="200">List of entries returned.</response> - /// <returns>List of entries.</returns> - [HttpGet("Drives")] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<FileSystemEntryInfo> GetDrives() - { - return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory)); - } + /// <summary> + /// Gets network paths. + /// </summary> + /// <response code="200">Empty array returned.</response> + /// <returns>List of entries.</returns> + [Obsolete("This endpoint is obsolete.")] + [HttpGet("NetworkShares")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares() + { + _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares"); + return Array.Empty<FileSystemEntryInfo>(); + } - /// <summary> - /// Gets the parent path of a given path. - /// </summary> - /// <param name="path">The path.</param> - /// <returns>Parent path.</returns> - [HttpGet("ParentPath")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<string?> GetParentPath([FromQuery, Required] string path) + /// <summary> + /// Gets available drives from the server's file system. + /// </summary> + /// <response code="200">List of entries returned.</response> + /// <returns>List of entries.</returns> + [HttpGet("Drives")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<FileSystemEntryInfo> GetDrives() + { + return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory)); + } + + /// <summary> + /// Gets the parent path of a given path. + /// </summary> + /// <param name="path">The path.</param> + /// <returns>Parent path.</returns> + [HttpGet("ParentPath")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<string?> GetParentPath([FromQuery, Required] string path) + { + string? parent = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(parent)) { - string? parent = Path.GetDirectoryName(path); - if (string.IsNullOrEmpty(parent)) + // Check if unc share + var index = path.LastIndexOf(UncSeparator); + + if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0) { - // Check if unc share - var index = path.LastIndexOf(UncSeparator); + parent = path.Substring(0, index); - if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0) + if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator))) { - parent = path.Substring(0, index); - - if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator))) - { - parent = null; - } + parent = null; } } - - return parent; } - /// <summary> - /// Get Default directory browser. - /// </summary> - /// <response code="200">Default directory browser returned.</response> - /// <returns>Default directory browser.</returns> - [HttpGet("DefaultDirectoryBrowser")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser() - { - return new DefaultDirectoryBrowserInfoDto(); - } + return parent; + } + + /// <summary> + /// Get Default directory browser. + /// </summary> + /// <response code="200">Default directory browser returned.</response> + /// <returns>Default directory browser.</returns> + [HttpGet("DefaultDirectoryBrowser")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser() + { + return new DefaultDirectoryBrowserInfoDto(); } } diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 17d136384..2378aada5 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -12,205 +12,204 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Filters controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class FilterController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="FilterController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public FilterController(ILibraryManager libraryManager, IUserManager userManager) + { + _libraryManager = libraryManager; + _userManager = userManager; + } + /// <summary> - /// Filters controller. + /// Gets legacy query filters. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class FilterController : BaseJellyfinApiController + /// <param name="userId">Optional. User id.</param> + /// <param name="parentId">Optional. Parent id.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <response code="200">Legacy filters retrieved.</response> + /// <returns>Legacy query filters.</returns> + [HttpGet("Items/Filters")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy( + [FromQuery] Guid? userId, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - - /// <summary> - /// Initializes a new instance of the <see cref="FilterController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public FilterController(ILibraryManager libraryManager, IUserManager userManager) + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + BaseItem? item = null; + if (includeItemTypes.Length != 1 + || !(includeItemTypes[0] == BaseItemKind.BoxSet + || includeItemTypes[0] == BaseItemKind.Playlist + || includeItemTypes[0] == BaseItemKind.Trailer + || includeItemTypes[0] == BaseItemKind.Program)) { - _libraryManager = libraryManager; - _userManager = userManager; + item = _libraryManager.GetParentItem(parentId, user?.Id); } - /// <summary> - /// Gets legacy query filters. - /// </summary> - /// <param name="userId">Optional. User id.</param> - /// <param name="parentId">Optional. Parent id.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> - /// <response code="200">Legacy filters retrieved.</response> - /// <returns>Legacy query filters.</returns> - [HttpGet("Items/Filters")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy( - [FromQuery] Guid? userId, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes) + var query = new InternalItemsQuery { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - BaseItem? item = null; - if (includeItemTypes.Length != 1 - || !(includeItemTypes[0] == BaseItemKind.BoxSet - || includeItemTypes[0] == BaseItemKind.Playlist - || includeItemTypes[0] == BaseItemKind.Trailer - || includeItemTypes[0] == BaseItemKind.Program)) + User = user, + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, + Recursive = true, + EnableTotalRecordCount = false, + DtoOptions = new DtoOptions { - item = _libraryManager.GetParentItem(parentId, user?.Id); + Fields = new[] { ItemFields.Genres, ItemFields.Tags }, + EnableImages = false, + EnableUserData = false } + }; - var query = new InternalItemsQuery - { - User = user, - MediaTypes = mediaTypes, - IncludeItemTypes = includeItemTypes, - Recursive = true, - EnableTotalRecordCount = false, - DtoOptions = new DtoOptions - { - Fields = new[] { ItemFields.Genres, ItemFields.Tags }, - EnableImages = false, - EnableUserData = false - } - }; - - if (item is not Folder folder) - { - return new QueryFiltersLegacy(); - } - - var itemList = folder.GetItemList(query); - return new QueryFiltersLegacy - { - Years = itemList.Select(i => i.ProductionYear ?? -1) - .Where(i => i > 0) - .Distinct() - .Order() - .ToArray(), - - Genres = itemList.SelectMany(i => i.Genres) - .DistinctNames() - .Order() - .ToArray(), - - Tags = itemList - .SelectMany(i => i.Tags) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order() - .ToArray(), - - OfficialRatings = itemList - .Select(i => i.OfficialRating) - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order() - .ToArray() - }; + if (item is not Folder folder) + { + return new QueryFiltersLegacy(); } - /// <summary> - /// Gets query filters. - /// </summary> - /// <param name="userId">Optional. User id.</param> - /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="isAiring">Optional. Is item airing.</param> - /// <param name="isMovie">Optional. Is item movie.</param> - /// <param name="isSports">Optional. Is item sports.</param> - /// <param name="isKids">Optional. Is item kids.</param> - /// <param name="isNews">Optional. Is item news.</param> - /// <param name="isSeries">Optional. Is item series.</param> - /// <param name="recursive">Optional. Search recursive.</param> - /// <response code="200">Filters retrieved.</response> - /// <returns>Query filters.</returns> - [HttpGet("Items/Filters2")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryFilters> GetQueryFilters( - [FromQuery] Guid? userId, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool? isAiring, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSports, - [FromQuery] bool? isKids, - [FromQuery] bool? isNews, - [FromQuery] bool? isSeries, - [FromQuery] bool? recursive) + var itemList = folder.GetItemList(query); + return new QueryFiltersLegacy { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - BaseItem? parentItem = null; - if (includeItemTypes.Length == 1 - && (includeItemTypes[0] == BaseItemKind.BoxSet - || includeItemTypes[0] == BaseItemKind.Playlist - || includeItemTypes[0] == BaseItemKind.Trailer - || includeItemTypes[0] == BaseItemKind.Program)) - { - parentItem = null; - } - else if (parentId.HasValue) - { - parentItem = _libraryManager.GetItemById(parentId.Value); - } + Years = itemList.Select(i => i.ProductionYear ?? -1) + .Where(i => i > 0) + .Distinct() + .Order() + .ToArray(), + + Genres = itemList.SelectMany(i => i.Genres) + .DistinctNames() + .Order() + .ToArray(), + + Tags = itemList + .SelectMany(i => i.Tags) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order() + .ToArray(), + + OfficialRatings = itemList + .Select(i => i.OfficialRating) + .Where(i => !string.IsNullOrWhiteSpace(i)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order() + .ToArray() + }; + } - var filters = new QueryFilters(); - var genreQuery = new InternalItemsQuery(user) - { - IncludeItemTypes = includeItemTypes, - DtoOptions = new DtoOptions - { - Fields = Array.Empty<ItemFields>(), - EnableImages = false, - EnableUserData = false - }, - IsAiring = isAiring, - IsMovie = isMovie, - IsSports = isSports, - IsKids = isKids, - IsNews = isNews, - IsSeries = isSeries - }; - - if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) - { - genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; - } - else + /// <summary> + /// Gets query filters. + /// </summary> + /// <param name="userId">Optional. User id.</param> + /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="isAiring">Optional. Is item airing.</param> + /// <param name="isMovie">Optional. Is item movie.</param> + /// <param name="isSports">Optional. Is item sports.</param> + /// <param name="isKids">Optional. Is item kids.</param> + /// <param name="isNews">Optional. Is item news.</param> + /// <param name="isSeries">Optional. Is item series.</param> + /// <param name="recursive">Optional. Search recursive.</param> + /// <response code="200">Filters retrieved.</response> + /// <returns>Query filters.</returns> + [HttpGet("Items/Filters2")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryFilters> GetQueryFilters( + [FromQuery] Guid? userId, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool? isAiring, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSports, + [FromQuery] bool? isKids, + [FromQuery] bool? isNews, + [FromQuery] bool? isSeries, + [FromQuery] bool? recursive) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + BaseItem? parentItem = null; + if (includeItemTypes.Length == 1 + && (includeItemTypes[0] == BaseItemKind.BoxSet + || includeItemTypes[0] == BaseItemKind.Playlist + || includeItemTypes[0] == BaseItemKind.Trailer + || includeItemTypes[0] == BaseItemKind.Program)) + { + parentItem = null; + } + else if (parentId.HasValue) + { + parentItem = _libraryManager.GetItemById(parentId.Value); + } + + var filters = new QueryFilters(); + var genreQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = includeItemTypes, + DtoOptions = new DtoOptions { - genreQuery.Parent = parentItem; - } + Fields = Array.Empty<ItemFields>(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + + if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) + { + genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + } + else + { + genreQuery.Parent = parentItem; + } - if (includeItemTypes.Length == 1 - && (includeItemTypes[0] == BaseItemKind.MusicAlbum - || includeItemTypes[0] == BaseItemKind.MusicVideo - || includeItemTypes[0] == BaseItemKind.MusicArtist - || includeItemTypes[0] == BaseItemKind.Audio)) + if (includeItemTypes.Length == 1 + && (includeItemTypes[0] == BaseItemKind.MusicAlbum + || includeItemTypes[0] == BaseItemKind.MusicVideo + || includeItemTypes[0] == BaseItemKind.MusicArtist + || includeItemTypes[0] == BaseItemKind.Audio)) + { + filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair { - filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair - { - Name = i.Item.Name, - Id = i.Item.Id - }).ToArray(); - } - else + Name = i.Item.Name, + Id = i.Item.Id + }).ToArray(); + } + else + { + filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair { - filters.Genres = _libraryManager.GetGenres(genreQuery).Items.Select(i => new NameGuidPair - { - Name = i.Item.Name, - Id = i.Item.Id - }).ToArray(); - } - - return filters; + Name = i.Item.Name, + Id = i.Item.Id + }).ToArray(); } + + return filters; } } diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index 611643bd8..28ebe2047 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -18,194 +18,193 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Genre = MediaBrowser.Controller.Entities.Genre; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The genres controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class GenresController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + /// <summary> - /// The genres controller. + /// Initializes a new instance of the <see cref="GenresController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class GenresController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public GenresController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - - /// <summary> - /// Initializes a new instance of the <see cref="GenresController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - public GenresController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - } + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + } - /// <summary> - /// Gets all genres from a given item, folder, or the entire library. - /// </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="searchTerm">The search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> - /// <response code="200">Genres returned.</response> - /// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetGenres( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool? isFavorite, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); + /// <summary> + /// Gets all genres from a given item, folder, or the entire library. + /// </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="searchTerm">The search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> + /// <response code="200">Genres returned.</response> + /// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetGenres( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool? isFavorite, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + User? user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - var parentItem = _libraryManager.GetParentItem(parentId, userId); + var parentItem = _libraryManager.GetParentItem(parentId, userId); - var query = new InternalItemsQuery(user) - { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) - }; - - if (parentId.HasValue) - { - if (parentItem is Folder) - { - query.AncestorIds = new[] { parentId.Value }; - } - else - { - query.ItemIds = new[] { parentId.Value }; - } - } - - QueryResult<(BaseItem, ItemCounts)> result; - if (parentItem is ICollectionFolder parentCollectionFolder - && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal) - || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal))) + var query = new InternalItemsQuery(user) + { + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) + }; + + if (parentId.HasValue) + { + if (parentItem is Folder) { - result = _libraryManager.GetMusicGenres(query); + query.AncestorIds = new[] { parentId.Value }; } else { - result = _libraryManager.GetGenres(query); + query.ItemIds = new[] { parentId.Value }; } - - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } - /// <summary> - /// Gets a genre, by name. - /// </summary> - /// <param name="genreName">The genre name.</param> - /// <param name="userId">The user id.</param> - /// <response code="200">Genres returned.</response> - /// <returns>An <see cref="OkResult"/> containing the genre.</returns> - [HttpGet("{genreName}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) + QueryResult<(BaseItem, ItemCounts)> result; + if (parentItem is ICollectionFolder parentCollectionFolder + && (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal) + || string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal))) + { + result = _libraryManager.GetMusicGenres(query); + } + else { - var dtoOptions = new DtoOptions() - .AddClientFields(User); + result = _libraryManager.GetGenres(query); + } - Genre? item; - if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase)) - { - item = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre); - } - else - { - item = _libraryManager.GetGenre(genreName); - } + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + } - item ??= new Genre(); + /// <summary> + /// Gets a genre, by name. + /// </summary> + /// <param name="genreName">The genre name.</param> + /// <param name="userId">The user id.</param> + /// <response code="200">Genres returned.</response> + /// <returns>An <see cref="OkResult"/> containing the genre.</returns> + [HttpGet("{genreName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) + { + var dtoOptions = new DtoOptions() + .AddClientFields(User); - if (userId is null || userId.Value.Equals(default)) - { - return _dtoService.GetBaseItemDto(item, dtoOptions); - } + Genre? item; + if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase)) + { + item = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions, BaseItemKind.Genre); + } + else + { + item = _libraryManager.GetGenre(genreName); + } - var user = _userManager.GetUserById(userId.Value); + item ??= new Genre(); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); + if (userId is null || userId.Value.Equals(default)) + { + return _dtoService.GetBaseItemDto(item, dtoOptions); } - private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) - where T : BaseItem, new() + var user = _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } + + private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) + where T : BaseItem, new() + { + var result = libraryManager.GetItemList(new InternalItemsQuery { - var result = libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '&'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + Name = name.Replace(BaseItem.SlugChar, '&'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '/'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '/'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '?'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '?'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 50fee233a..085115e1c 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -15,178 +15,177 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The hls segment controller. +/// </summary> +[Route("")] +public class HlsSegmentController : BaseJellyfinApiController { + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + + /// <summary> + /// Initializes a new instance of the <see cref="HlsSegmentController"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="transcodingJobHelper">Initialized instance of the <see cref="TranscodingJobHelper"/>.</param> + public HlsSegmentController( + IFileSystem fileSystem, + IServerConfigurationManager serverConfigurationManager, + TranscodingJobHelper transcodingJobHelper) + { + _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; + _transcodingJobHelper = transcodingJobHelper; + } + /// <summary> - /// The hls segment controller. + /// Gets the specified audio segment for an audio item. /// </summary> - [Route("")] - public class HlsSegmentController : BaseJellyfinApiController + /// <param name="itemId">The item id.</param> + /// <param name="segmentId">The segment id.</param> + /// <response code="200">Hls audio segment returned.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the audio stream.</returns> + // Can't require authentication just yet due to seeing some requests come from Chrome without full query string + // [Authenticated] + [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) { - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - - /// <summary> - /// Initializes a new instance of the <see cref="HlsSegmentController"/> class. - /// </summary> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="transcodingJobHelper">Initialized instance of the <see cref="TranscodingJobHelper"/>.</param> - public HlsSegmentController( - IFileSystem fileSystem, - IServerConfigurationManager serverConfigurationManager, - TranscodingJobHelper transcodingJobHelper) + // TODO: Deprecate with new iOS app + var file = segmentId + Path.GetExtension(Request.Path); + var transcodePath = _serverConfigurationManager.GetTranscodePath(); + file = Path.GetFullPath(Path.Combine(transcodePath, file)); + var fileDir = Path.GetDirectoryName(file); + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)) { - _fileSystem = fileSystem; - _serverConfigurationManager = serverConfigurationManager; - _transcodingJobHelper = transcodingJobHelper; + return BadRequest("Invalid segment."); } - /// <summary> - /// Gets the specified audio segment for an audio item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="segmentId">The segment id.</param> - /// <response code="200">Hls audio segment returned.</response> - /// <returns>A <see cref="FileStreamResult"/> containing the audio stream.</returns> - // Can't require authentication just yet due to seeing some requests come from Chrome without full query string - // [Authenticated] - [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) - { - // TODO: Deprecate with new iOS app - var file = segmentId + Path.GetExtension(Request.Path); - var transcodePath = _serverConfigurationManager.GetTranscodePath(); - file = Path.GetFullPath(Path.Combine(transcodePath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)) - { - return BadRequest("Invalid segment."); - } + return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)); + } - return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)); + /// <summary> + /// Gets a hls video playlist. + /// </summary> + /// <param name="itemId">The video id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <response code="200">Hls video playlist returned.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the playlist.</returns> + [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) + { + var file = playlistId + Path.GetExtension(Request.Path); + var transcodePath = _serverConfigurationManager.GetTranscodePath(); + file = Path.GetFullPath(Path.Combine(transcodePath, file)); + var fileDir = Path.GetDirectoryName(file); + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) || Path.GetExtension(file) != ".m3u8") + { + return BadRequest("Invalid segment."); } - /// <summary> - /// Gets a hls video playlist. - /// </summary> - /// <param name="itemId">The video id.</param> - /// <param name="playlistId">The playlist id.</param> - /// <response code="200">Hls video playlist returned.</response> - /// <returns>A <see cref="FileStreamResult"/> containing the playlist.</returns> - [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) - { - var file = playlistId + Path.GetExtension(Request.Path); - var transcodePath = _serverConfigurationManager.GetTranscodePath(); - file = Path.GetFullPath(Path.Combine(transcodePath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) || Path.GetExtension(file) != ".m3u8") - { - return BadRequest("Invalid segment."); - } + return GetFileResult(file, file); + } - return GetFileResult(file, file); - } + /// <summary> + /// Stops an active encoding. + /// </summary> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="playSessionId">The play session id.</param> + /// <response code="204">Encoding stopped successfully.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpDelete("Videos/ActiveEncodings")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult StopEncodingProcess( + [FromQuery, Required] string deviceId, + [FromQuery, Required] string playSessionId) + { + _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true); + return NoContent(); + } + + /// <summary> + /// Gets a hls video segment. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="playlistId">The playlist id.</param> + /// <param name="segmentId">The segment id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <response code="200">Hls video segment returned.</response> + /// <response code="404">Hls segment not found.</response> + /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns> + // Can't require authentication just yet due to seeing some requests come from Chrome without full query string + // [Authenticated] + [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesVideoFile] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] + public ActionResult GetHlsVideoSegmentLegacy( + [FromRoute, Required] string itemId, + [FromRoute, Required] string playlistId, + [FromRoute, Required] string segmentId, + [FromRoute, Required] string segmentContainer) + { + var file = segmentId + Path.GetExtension(Request.Path); + var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); - /// <summary> - /// Stops an active encoding. - /// </summary> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="playSessionId">The play session id.</param> - /// <response code="204">Encoding stopped successfully.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpDelete("Videos/ActiveEncodings")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult StopEncodingProcess( - [FromQuery, Required] string deviceId, - [FromQuery, Required] string playSessionId) + file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); + var fileDir = Path.GetDirectoryName(file); + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) { - _transcodingJobHelper.KillTranscodingJobs(deviceId, playSessionId, path => true); - return NoContent(); + return BadRequest("Invalid segment."); } - /// <summary> - /// Gets a hls video segment. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="playlistId">The playlist id.</param> - /// <param name="segmentId">The segment id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <response code="200">Hls video segment returned.</response> - /// <response code="404">Hls segment not found.</response> - /// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns> - // Can't require authentication just yet due to seeing some requests come from Chrome without full query string - // [Authenticated] - [HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesVideoFile] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] - public ActionResult GetHlsVideoSegmentLegacy( - [FromRoute, Required] string itemId, - [FromRoute, Required] string playlistId, - [FromRoute, Required] string segmentId, - [FromRoute, Required] string segmentContainer) - { - var file = segmentId + Path.GetExtension(Request.Path); - var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); + var normalizedPlaylistId = playlistId; - file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); - var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture)) + var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); + // Add . to start of segment container for future use. + segmentContainer = segmentContainer.Insert(0, "."); + string? playlistPath = null; + foreach (var path in filePaths) + { + var pathExtension = Path.GetExtension(path); + if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) + || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) + && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1) { - return BadRequest("Invalid segment."); + playlistPath = path; + break; } + } - var normalizedPlaylistId = playlistId; - - var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); - // Add . to start of segment container for future use. - segmentContainer = segmentContainer.Insert(0, "."); - string? playlistPath = null; - foreach (var path in filePaths) - { - var pathExtension = Path.GetExtension(path); - if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) - || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) - && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1) - { - playlistPath = path; - break; - } - } + return playlistPath is null + ? NotFound("Hls segment not found.") + : GetFileResult(file, playlistPath); + } - return playlistPath is null - ? NotFound("Hls segment not found.") - : GetFileResult(file, playlistPath); - } + private ActionResult GetFileResult(string path, string playlistPath) + { + var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); - private ActionResult GetFileResult(string path, string playlistPath) + Response.OnCompleted(() => { - var transcodingJob = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); - - Response.OnCompleted(() => + if (transcodingJob is not null) { - if (transcodingJob is not null) - { - _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); - } + _transcodingJobHelper.OnTranscodeEndRequest(transcodingJob); + } - return Task.CompletedTask; - }); + return Task.CompletedTask; + }); - return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)); - } + return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)); } } diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 996dc0819..cc824c65a 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -30,2071 +30,2070 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Image controller. +/// </summary> +[Route("")] +public class ImageController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IImageProcessor _imageProcessor; + private readonly IFileSystem _fileSystem; + private readonly ILogger<ImageController> _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IApplicationPaths _appPaths; + + /// <summary> + /// Initializes a new instance of the <see cref="ImageController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ImageController}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + public ImageController( + IUserManager userManager, + ILibraryManager libraryManager, + IProviderManager providerManager, + IImageProcessor imageProcessor, + IFileSystem fileSystem, + ILogger<ImageController> logger, + IServerConfigurationManager serverConfigurationManager, + IApplicationPaths appPaths) + { + _userManager = userManager; + _libraryManager = libraryManager; + _providerManager = providerManager; + _imageProcessor = imageProcessor; + _fileSystem = fileSystem; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + _appPaths = appPaths; + } + /// <summary> - /// Image controller. + /// Sets the user image. /// </summary> - [Route("")] - public class ImageController : BaseJellyfinApiController + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image updated.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/Images/{imageType}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [AcceptsImageFile] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> PostUserImage( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? index = null) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly IImageProcessor _imageProcessor; - private readonly IFileSystem _fileSystem; - private readonly ILogger<ImageController> _logger; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IApplicationPaths _appPaths; - - /// <summary> - /// Initializes a new instance of the <see cref="ImageController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - /// <param name="imageProcessor">Instance of the <see cref="IImageProcessor"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{ImageController}"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> - public ImageController( - IUserManager userManager, - ILibraryManager libraryManager, - IProviderManager providerManager, - IImageProcessor imageProcessor, - IFileSystem fileSystem, - ILogger<ImageController> logger, - IServerConfigurationManager serverConfigurationManager, - IApplicationPaths appPaths) - { - _userManager = userManager; - _libraryManager = libraryManager; - _providerManager = providerManager; - _imageProcessor = imageProcessor; - _fileSystem = fileSystem; - _logger = logger; - _serverConfigurationManager = serverConfigurationManager; - _appPaths = appPaths; - } - - /// <summary> - /// Sets the user image. - /// </summary> - /// <param name="userId">User Id.</param> - /// <param name="imageType">(Unused) Image type.</param> - /// <param name="index">(Unused) Image index.</param> - /// <response code="204">Image updated.</response> - /// <response code="403">User does not have permission to delete the image.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Users/{userId}/Images/{imageType}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [AcceptsImageFile] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> PostUserImage( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? index = null) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); + } + + var user = _userManager.GetUserById(userId); + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) + { + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); + var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + if (user.ProfileImage is not null) { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); } - var user = _userManager.GetUserById(userId); - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) - { - // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); - if (user.ProfileImage is not null) - { - await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); - } + user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty))); - user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty))); + await _providerManager + .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) + .ConfigureAwait(false); + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); - await _providerManager - .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) - .ConfigureAwait(false); - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + return NoContent(); + } + } - return NoContent(); - } + /// <summary> + /// Sets the user image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image updated.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/Images/{imageType}/{index}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [AcceptsImageFile] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> PostUserImageByIndex( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int index) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); } - /// <summary> - /// Sets the user image. - /// </summary> - /// <param name="userId">User Id.</param> - /// <param name="imageType">(Unused) Image type.</param> - /// <param name="index">(Unused) Image index.</param> - /// <response code="204">Image updated.</response> - /// <response code="403">User does not have permission to delete the image.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Users/{userId}/Images/{imageType}/{index}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [AcceptsImageFile] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> PostUserImageByIndex( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int index) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + var user = _userManager.GetUserById(userId); + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) + { + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); + var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + if (user.ProfileImage is not null) { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the image."); + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); } - var user = _userManager.GetUserById(userId); - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) - { - // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); - if (user.ProfileImage is not null) - { - await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); - } + user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty))); + + await _providerManager + .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) + .ConfigureAwait(false); + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); - user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType ?? string.Empty))); + return NoContent(); + } + } - await _providerManager - .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) - .ConfigureAwait(false); - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + /// <summary> + /// Delete the user's image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Users/{userId}/Images/{imageType}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> DeleteUserImage( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? index = null) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); + } - return NoContent(); - } + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage is null) + { + return NoContent(); } - /// <summary> - /// Delete the user's image. - /// </summary> - /// <param name="userId">User Id.</param> - /// <param name="imageType">(Unused) Image type.</param> - /// <param name="index">(Unused) Image index.</param> - /// <response code="204">Image deleted.</response> - /// <response code="403">User does not have permission to delete the image.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Users/{userId}/Images/{imageType}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult> DeleteUserImage( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? index = null) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); - } + try + { + System.IO.File.Delete(user.ProfileImage.Path); + } + catch (IOException e) + { + _logger.LogError(e, "Error deleting user profile image:"); + } - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) - { - return NoContent(); - } + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + return NoContent(); + } - try - { - System.IO.File.Delete(user.ProfileImage.Path); - } - catch (IOException e) - { - _logger.LogError(e, "Error deleting user profile image:"); - } + /// <summary> + /// Delete the user's image. + /// </summary> + /// <param name="userId">User Id.</param> + /// <param name="imageType">(Unused) Image type.</param> + /// <param name="index">(Unused) Image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="403">User does not have permission to delete the image.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Users/{userId}/Images/{imageType}/{index}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> DeleteUserImageByIndex( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int index) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); + } - await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage is null) + { return NoContent(); } - /// <summary> - /// Delete the user's image. - /// </summary> - /// <param name="userId">User Id.</param> - /// <param name="imageType">(Unused) Image type.</param> - /// <param name="index">(Unused) Image index.</param> - /// <response code="204">Image deleted.</response> - /// <response code="403">User does not have permission to delete the image.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Users/{userId}/Images/{imageType}/{index}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult> DeleteUserImageByIndex( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int index) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, HttpContext.User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to delete the image."); - } + try + { + System.IO.File.Delete(user.ProfileImage.Path); + } + catch (IOException e) + { + _logger.LogError(e, "Error deleting user profile image:"); + } - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) - { - return NoContent(); - } + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + return NoContent(); + } - try - { - System.IO.File.Delete(user.ProfileImage.Path); - } - catch (IOException e) - { - _logger.LogError(e, "Error deleting user profile image:"); - } + /// <summary> + /// Delete an item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">The image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpDelete("Items/{itemId}/Images/{imageType}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } - await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); - return NoContent(); + await item.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Delete an item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">The image index.</param> + /// <response code="204">Image deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteItemImageByIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Delete an item's image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">The image index.</param> - /// <response code="204">Image deleted.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpDelete("Items/{itemId}/Images/{imageType}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteItemImage( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false); + return NoContent(); + } - await item.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false); - return NoContent(); + /// <summary> + /// Set item image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <response code="204">Image saved.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpPost("Items/{itemId}/Images/{imageType}")] + [Authorize(Policy = Policies.RequiresElevation)] + [AcceptsImageFile] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> SetItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Delete an item's image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">The image index.</param> - /// <response code="204">Image deleted.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteItemImageByIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int imageIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) + { + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); + await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false); return NoContent(); } + } - /// <summary> - /// Set item image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <response code="204">Image saved.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpPost("Items/{itemId}/Images/{imageType}")] - [Authorize(Policy = Policies.RequiresElevation)] - [AcceptsImageFile] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> SetItemImage( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + /// <summary> + /// Set item image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">(Unused) Image index.</param> + /// <response code="204">Image saved.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [Authorize(Policy = Policies.RequiresElevation)] + [AcceptsImageFile] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> SetItemImageByIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) - { - // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); - await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) + { + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); + await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + return NoContent(); } + } - /// <summary> - /// Set item image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">(Unused) Image index.</param> - /// <response code="204">Image saved.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")] - [Authorize(Policy = Policies.RequiresElevation)] - [AcceptsImageFile] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> SetItemImageByIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int imageIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + /// <summary> + /// Updates the index for an item image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Old image index.</param> + /// <param name="newIndex">New image index.</param> + /// <response code="204">Image index updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdateItemImageIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery, Required] int newIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) - { - // Handle image/png; charset=utf-8 - var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); - await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); + await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false); + return NoContent(); + } - return NoContent(); - } + /// <summary> + /// Get item image infos. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item images returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The list of image infos on success, or <see cref="NotFoundResult"/> if item not found.</returns> + [HttpGet("Items/{itemId}/Images")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Updates the index for an item image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Old image index.</param> - /// <param name="newIndex">New image index.</param> - /// <response code="204">Image index updated.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}/Index")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> UpdateItemImageIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery, Required] int newIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + var list = new List<ImageInfo>(); + var itemImages = item.ImageInfos; - await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false); - return NoContent(); + if (itemImages.Length == 0) + { + // short-circuit + return list; } - /// <summary> - /// Get item image infos. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <response code="200">Item images returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>The list of image infos on success, or <see cref="NotFoundResult"/> if item not found.</returns> - [HttpGet("Items/{itemId}/Images")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + await _libraryManager.UpdateImagesAsync(item).ConfigureAwait(false); // this makes sure dimensions and hashes are correct - var list = new List<ImageInfo>(); - var itemImages = item.ImageInfos; - - if (itemImages.Length == 0) + foreach (var image in itemImages) + { + if (!item.AllowsMultipleImages(image.Type)) { - // short-circuit - return list; - } + var info = GetImageInfo(item, image, null); - await _libraryManager.UpdateImagesAsync(item).ConfigureAwait(false); // this makes sure dimensions and hashes are correct - - foreach (var image in itemImages) - { - if (!item.AllowsMultipleImages(image.Type)) + if (info is not null) { - var info = GetImageInfo(item, image, null); - - if (info is not null) - { - list.Add(info); - } + list.Add(info); } } + } - foreach (var imageType in itemImages.Select(i => i.Type).Distinct().Where(item.AllowsMultipleImages)) - { - var index = 0; - - // Prevent implicitly captured closure - var currentImageType = imageType; + foreach (var imageType in itemImages.Select(i => i.Type).Distinct().Where(item.AllowsMultipleImages)) + { + var index = 0; - foreach (var image in itemImages.Where(i => i.Type == currentImageType)) - { - var info = GetImageInfo(item, image, index); + // Prevent implicitly captured closure + var currentImageType = imageType; - if (info is not null) - { - list.Add(info); - } + foreach (var image in itemImages.Where(i => i.Type == currentImageType)) + { + var info = GetImageInfo(item, image, index); - index++; + if (info is not null) + { + list.Add(info); } + + index++; } + } - return list; + return list; + } + + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}")] + [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetItemImage( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery] string? tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] ImageFormat? format, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Gets the item's image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Items/{itemId}/Images/{imageType}")] - [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetItemImage( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery] string? tag, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] ImageFormat? format, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - itemId, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")] + [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetItemImageByIndex( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] ImageType imageType, + [FromRoute] int imageIndex, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery] string? tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] ImageFormat? format, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Gets the item's image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")] - [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetItemImageByIndex( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] ImageType imageType, - [FromRoute] int imageIndex, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery] string? tag, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] ImageFormat? format, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - itemId, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Gets the item's image. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")] + [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, + [FromRoute, Required] int maxWidth, + [FromRoute, Required] int maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromRoute, Required] string tag, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromRoute, Required] ImageFormat format, + [FromRoute, Required] double percentPlayed, + [FromRoute, Required] int unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute, Required] int imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Gets the item's image. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}")] - [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, - [FromRoute, Required] int maxWidth, - [FromRoute, Required] int maxHeight, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromRoute, Required] string tag, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromRoute, Required] ImageFormat format, - [FromRoute, Required] double percentPlayed, - [FromRoute, Required] int unplayedCount, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromRoute, Required] int imageIndex) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - itemId, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get artist image by name. + /// </summary> + /// <param name="name">Artist name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")] + [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, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromRoute, Required] int imageIndex) + { + var item = _libraryManager.GetArtist(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get artist image by name. - /// </summary> - /// <param name="name">Artist name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")] - [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, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromRoute, Required] int imageIndex) - { - var item = _libraryManager.GetArtist(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get genre image by name. + /// </summary> + /// <param name="name">Genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Genres/{name}/Images/{imageType}")] + [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetGenreImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetGenre(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get genre image by name. - /// </summary> - /// <param name="name">Genre name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Genres/{name}/Images/{imageType}")] - [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetGenreImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetGenre(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get genre image by name. + /// </summary> + /// <param name="name">Genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetGenreImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetGenre(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get genre image by name. - /// </summary> - /// <param name="name">Genre name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetGenreImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetGenre(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get music genre image by name. + /// </summary> + /// <param name="name">Music genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("MusicGenres/{name}/Images/{imageType}")] + [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetMusicGenreImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetMusicGenre(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get music genre image by name. - /// </summary> - /// <param name="name">Music genre name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("MusicGenres/{name}/Images/{imageType}")] - [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetMusicGenreImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetMusicGenre(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get music genre image by name. + /// </summary> + /// <param name="name">Music genre name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetMusicGenreImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetMusicGenre(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get music genre image by name. - /// </summary> - /// <param name="name">Music genre name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetMusicGenreImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetMusicGenre(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get person image by name. + /// </summary> + /// <param name="name">Person name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Persons/{name}/Images/{imageType}")] + [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetPersonImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetPerson(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get person image by name. - /// </summary> - /// <param name="name">Person name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Persons/{name}/Images/{imageType}")] - [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetPersonImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetPerson(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get person image by name. + /// </summary> + /// <param name="name">Person name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetPersonImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetPerson(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get person image by name. - /// </summary> - /// <param name="name">Person name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetPersonImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetPerson(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get studio image by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Studios/{name}/Images/{imageType}")] + [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetStudioImage( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetStudio(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get studio image by name. - /// </summary> - /// <param name="name">Studio name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Studios/{name}/Images/{imageType}")] - [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetStudioImage( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var item = _libraryManager.GetStudio(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get studio image by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetStudioImageByIndex( + [FromRoute, Required] string name, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var item = _libraryManager.GetStudio(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get studio image by name. - /// </summary> - /// <param name="name">Studio name.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")] - [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetStudioImageByIndex( - [FromRoute, Required] string name, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var item = _libraryManager.GetStudio(name); - if (item is null) - { - return NotFound(); - } + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + item) + .ConfigureAwait(false); + } - return await GetImageInternal( - item.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - item) - .ConfigureAwait(false); + /// <summary> + /// Get user profile image. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <param name="imageIndex">Image index.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Users/{userId}/Images/{imageType}")] + [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetUserImage( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery] int? imageIndex) + { + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage is null) + { + return NotFound(); } - /// <summary> - /// Get user profile image. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <param name="imageIndex">Image index.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Users/{userId}/Images/{imageType}")] - [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetUserImage( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery] int? imageIndex) - { - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) - { - return NotFound(); - } + var info = new ItemImageInfo + { + Path = user.ProfileImage.Path, + Type = ImageType.Profile, + DateModified = user.ProfileImage.LastModified + }; - var info = new ItemImageInfo - { - Path = user.ProfileImage.Path, - Type = ImageType.Profile, - DateModified = user.ProfileImage.LastModified - }; + if (width.HasValue) + { + info.Width = width.Value; + } - if (width.HasValue) - { - info.Width = width.Value; - } + if (height.HasValue) + { + info.Height = height.Value; + } - if (height.HasValue) - { - info.Height = height.Value; - } + return await GetImageInternal( + user.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + null, + info) + .ConfigureAwait(false); + } - return await GetImageInternal( - user.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - null, - info) - .ConfigureAwait(false); + /// <summary> + /// Get user profile image. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="imageType">Image type.</param> + /// <param name="imageIndex">Image index.</param> + /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> + /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> + /// <param name="blur">Optional. Blur image.</param> + /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> + /// <response code="200">Image stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns> + /// A <see cref="FileStreamResult"/> containing the file stream on success, + /// or a <see cref="NotFoundResult"/> if item not found. + /// </returns> + [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")] + [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + public async Task<ActionResult> GetUserImageByIndex( + [FromRoute, Required] Guid userId, + [FromRoute, Required] ImageType imageType, + [FromRoute, Required] int imageIndex, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? quality, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery, ParameterObsolete] bool? cropWhitespace, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage is null) + { + return NotFound(); } - /// <summary> - /// Get user profile image. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="imageType">Image type.</param> - /// <param name="imageIndex">Image index.</param> - /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param> - /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param> - /// <param name="blur">Optional. Blur image.</param> - /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param> - /// <response code="200">Image stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns> - /// A <see cref="FileStreamResult"/> containing the file stream on success, - /// or a <see cref="NotFoundResult"/> if item not found. - /// </returns> - [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")] - [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - public async Task<ActionResult> GetUserImageByIndex( - [FromRoute, Required] Guid userId, - [FromRoute, Required] ImageType imageType, - [FromRoute, Required] int imageIndex, - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] double? percentPlayed, - [FromQuery] int? unplayedCount, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? quality, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery, ParameterObsolete] bool? cropWhitespace, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer) - { - var user = _userManager.GetUserById(userId); - if (user?.ProfileImage is null) - { - return NotFound(); - } + var info = new ItemImageInfo + { + Path = user.ProfileImage.Path, + Type = ImageType.Profile, + DateModified = user.ProfileImage.LastModified + }; - var info = new ItemImageInfo - { - Path = user.ProfileImage.Path, - Type = ImageType.Profile, - DateModified = user.ProfileImage.LastModified - }; + if (width.HasValue) + { + info.Width = width.Value; + } - if (width.HasValue) - { - info.Width = width.Value; - } + if (height.HasValue) + { + info.Height = height.Value; + } - if (height.HasValue) - { - info.Height = height.Value; - } + return await GetImageInternal( + user.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + fillWidth, + fillHeight, + blur, + backgroundColor, + foregroundLayer, + null, + info) + .ConfigureAwait(false); + } - return await GetImageInternal( - user.Id, - imageType, - imageIndex, - tag, - format, - maxWidth, - maxHeight, - percentPlayed, - unplayedCount, - width, - height, - quality, - fillWidth, - fillHeight, - blur, - backgroundColor, - foregroundLayer, - null, - info) - .ConfigureAwait(false); + /// <summary> + /// Generates or gets the splashscreen. + /// </summary> + /// <param name="tag">Supply the cache tag from the item object to receive strong caching headers.</param> + /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> + /// <param name="maxWidth">The maximum image width to return.</param> + /// <param name="maxHeight">The maximum image height to return.</param> + /// <param name="width">The fixed image width to return.</param> + /// <param name="height">The fixed image height to return.</param> + /// <param name="fillWidth">Width of box to fill.</param> + /// <param name="fillHeight">Height of box to fill.</param> + /// <param name="blur">Blur image.</param> + /// <param name="backgroundColor">Apply a background color for transparent images.</param> + /// <param name="foregroundLayer">Apply a foreground layer on top of the image.</param> + /// <param name="quality">Quality setting, from 0-100.</param> + /// <response code="200">Splashscreen returned successfully.</response> + /// <returns>The splashscreen.</returns> + [HttpGet("Branding/Splashscreen")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesImageFile] + public async Task<ActionResult> GetSplashscreen( + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? fillWidth, + [FromQuery] int? fillHeight, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer, + [FromQuery, Range(0, 100)] int quality = 90) + { + var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + if (!brandingOptions.SplashscreenEnabled) + { + return NotFound(); } - /// <summary> - /// Generates or gets the splashscreen. - /// </summary> - /// <param name="tag">Supply the cache tag from the item object to receive strong caching headers.</param> - /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param> - /// <param name="maxWidth">The maximum image width to return.</param> - /// <param name="maxHeight">The maximum image height to return.</param> - /// <param name="width">The fixed image width to return.</param> - /// <param name="height">The fixed image height to return.</param> - /// <param name="fillWidth">Width of box to fill.</param> - /// <param name="fillHeight">Height of box to fill.</param> - /// <param name="blur">Blur image.</param> - /// <param name="backgroundColor">Apply a background color for transparent images.</param> - /// <param name="foregroundLayer">Apply a foreground layer on top of the image.</param> - /// <param name="quality">Quality setting, from 0-100.</param> - /// <response code="200">Splashscreen returned successfully.</response> - /// <returns>The splashscreen.</returns> - [HttpGet("Branding/Splashscreen")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesImageFile] - public async Task<ActionResult> GetSplashscreen( - [FromQuery] string? tag, - [FromQuery] ImageFormat? format, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? fillWidth, - [FromQuery] int? fillHeight, - [FromQuery] int? blur, - [FromQuery] string? backgroundColor, - [FromQuery] string? foregroundLayer, - [FromQuery, Range(0, 100)] int quality = 90) + string splashscreenPath; + + if (!string.IsNullOrWhiteSpace(brandingOptions.SplashscreenLocation) + && System.IO.File.Exists(brandingOptions.SplashscreenLocation)) { - var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - if (!brandingOptions.SplashscreenEnabled) + splashscreenPath = brandingOptions.SplashscreenLocation; + } + else + { + splashscreenPath = Path.Combine(_appPaths.DataPath, "splashscreen.png"); + if (!System.IO.File.Exists(splashscreenPath)) { return NotFound(); } + } + + var outputFormats = GetOutputFormats(format); + + TimeSpan? cacheDuration = null; + if (!string.IsNullOrEmpty(tag)) + { + cacheDuration = TimeSpan.FromDays(365); + } + + var options = new ImageProcessingOptions + { + Image = new ItemImageInfo + { + Path = splashscreenPath + }, + Height = height, + MaxHeight = maxHeight, + MaxWidth = maxWidth, + FillHeight = fillHeight, + FillWidth = fillWidth, + Quality = quality, + Width = width, + Blur = blur, + BackgroundColor = backgroundColor, + ForegroundLayer = foregroundLayer, + SupportedOutputFormats = outputFormats + }; + + return await GetImageResult( + options, + cacheDuration, + ImmutableDictionary<string, string>.Empty) + .ConfigureAwait(false); + } - string splashscreenPath; + /// <summary> + /// Uploads a custom splashscreen. + /// The body is expected to the image contents base64 encoded. + /// </summary> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + /// <response code="204">Successfully uploaded new splashscreen.</response> + /// <response code="400">Error reading MimeType from uploaded image.</response> + /// <response code="403">User does not have permission to upload splashscreen..</response> + /// <exception cref="ArgumentException">Error reading the image format.</exception> + [HttpPost("Branding/Splashscreen")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [AcceptsImageFile] + public async Task<ActionResult> UploadCustomSplashscreen() + { + var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); + await using (memoryStream.ConfigureAwait(false)) + { + var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType; - if (!string.IsNullOrWhiteSpace(brandingOptions.SplashscreenLocation) - && System.IO.File.Exists(brandingOptions.SplashscreenLocation)) + if (!mimeType.HasValue) { - splashscreenPath = brandingOptions.SplashscreenLocation; + return BadRequest("Error reading mimetype from uploaded image"); } - else + + var extension = MimeTypes.ToExtension(mimeType.Value); + if (string.IsNullOrEmpty(extension)) { - splashscreenPath = Path.Combine(_appPaths.DataPath, "splashscreen.png"); - if (!System.IO.File.Exists(splashscreenPath)) - { - return NotFound(); - } + return BadRequest("Error converting mimetype to an image extension"); } - var outputFormats = GetOutputFormats(format); + var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension); + var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + brandingOptions.SplashscreenLocation = filePath; + _serverConfigurationManager.SaveConfiguration("branding", brandingOptions); - TimeSpan? cacheDuration = null; - if (!string.IsNullOrEmpty(tag)) + var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + await using (fs.ConfigureAwait(false)) { - cacheDuration = TimeSpan.FromDays(365); + await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false); } - var options = new ImageProcessingOptions - { - Image = new ItemImageInfo - { - Path = splashscreenPath - }, - Height = height, - MaxHeight = maxHeight, - MaxWidth = maxWidth, - FillHeight = fillHeight, - FillWidth = fillWidth, - Quality = quality, - Width = width, - Blur = blur, - BackgroundColor = backgroundColor, - ForegroundLayer = foregroundLayer, - SupportedOutputFormats = outputFormats - }; + return NoContent(); + } + } - return await GetImageResult( - options, - cacheDuration, - ImmutableDictionary<string, string>.Empty) - .ConfigureAwait(false); + /// <summary> + /// Delete a custom splashscreen. + /// </summary> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + /// <response code="204">Successfully deleted the custom splashscreen.</response> + /// <response code="403">User does not have permission to delete splashscreen..</response> + [HttpDelete("Branding/Splashscreen")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteCustomSplashscreen() + { + var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); + if (!string.IsNullOrEmpty(brandingOptions.SplashscreenLocation) + && System.IO.File.Exists(brandingOptions.SplashscreenLocation)) + { + System.IO.File.Delete(brandingOptions.SplashscreenLocation); + brandingOptions.SplashscreenLocation = null; + _serverConfigurationManager.SaveConfiguration("branding", brandingOptions); } - /// <summary> - /// Uploads a custom splashscreen. - /// The body is expected to the image contents base64 encoded. - /// </summary> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - /// <response code="204">Successfully uploaded new splashscreen.</response> - /// <response code="400">Error reading MimeType from uploaded image.</response> - /// <response code="403">User does not have permission to upload splashscreen..</response> - /// <exception cref="ArgumentException">Error reading the image format.</exception> - [HttpPost("Branding/Splashscreen")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [AcceptsImageFile] - public async Task<ActionResult> UploadCustomSplashscreen() - { - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) - { - var mimeType = MediaTypeHeaderValue.Parse(Request.ContentType).MediaType; + return NoContent(); + } - if (!mimeType.HasValue) - { - return BadRequest("Error reading mimetype from uploaded image"); - } + private static async Task<MemoryStream> GetMemoryStream(Stream inputStream) + { + using var reader = new StreamReader(inputStream); + var text = await reader.ReadToEndAsync().ConfigureAwait(false); - var extension = MimeTypes.ToExtension(mimeType.Value); - if (string.IsNullOrEmpty(extension)) - { - return BadRequest("Error converting mimetype to an image extension"); - } + var bytes = Convert.FromBase64String(text); + return new MemoryStream(bytes, 0, bytes.Length, false, true); + } - var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension); - var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - brandingOptions.SplashscreenLocation = filePath; - _serverConfigurationManager.SaveConfiguration("branding", brandingOptions); + private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex) + { + int? width = null; + int? height = null; + string? blurhash = null; + long length = 0; + + try + { + if (info.IsLocalFile) + { + var fileInfo = _fileSystem.GetFileInfo(info.Path); + length = fileInfo.Length; - var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - await using (fs.ConfigureAwait(false)) + blurhash = info.BlurHash; + width = info.Width; + height = info.Height; + + if (width <= 0 || height <= 0) { - await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false); + width = null; + height = null; } - - return NoContent(); } } - - /// <summary> - /// Delete a custom splashscreen. - /// </summary> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - /// <response code="204">Successfully deleted the custom splashscreen.</response> - /// <response code="403">User does not have permission to delete splashscreen..</response> - [HttpDelete("Branding/Splashscreen")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult DeleteCustomSplashscreen() + catch (Exception ex) { - var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); - if (!string.IsNullOrEmpty(brandingOptions.SplashscreenLocation) - && System.IO.File.Exists(brandingOptions.SplashscreenLocation)) - { - System.IO.File.Delete(brandingOptions.SplashscreenLocation); - brandingOptions.SplashscreenLocation = null; - _serverConfigurationManager.SaveConfiguration("branding", brandingOptions); - } - - return NoContent(); + _logger.LogError(ex, "Error getting image information for {Item}", item.Name); } - private static async Task<MemoryStream> GetMemoryStream(Stream inputStream) + try { - using var reader = new StreamReader(inputStream); - var text = await reader.ReadToEndAsync().ConfigureAwait(false); - - var bytes = Convert.FromBase64String(text); - return new MemoryStream(bytes, 0, bytes.Length, false, true); + return new ImageInfo + { + Path = info.Path, + ImageIndex = imageIndex, + ImageType = info.Type, + ImageTag = _imageProcessor.GetImageCacheTag(item, info), + Size = length, + BlurHash = blurhash, + Width = width, + Height = height + }; } - - private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex) + catch (Exception ex) { - int? width = null; - int? height = null; - string? blurhash = null; - long length = 0; - - try - { - if (info.IsLocalFile) - { - var fileInfo = _fileSystem.GetFileInfo(info.Path); - length = fileInfo.Length; - - blurhash = info.BlurHash; - width = info.Width; - height = info.Height; - - if (width <= 0 || height <= 0) - { - width = null; - height = null; - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting image information for {Item}", item.Name); - } + _logger.LogError(ex, "Error getting image information for {Path}", info.Path); + return null; + } + } - try + private async Task<ActionResult> GetImageInternal( + Guid itemId, + ImageType imageType, + int? imageIndex, + string? tag, + ImageFormat? format, + int? maxWidth, + int? maxHeight, + double? percentPlayed, + int? unplayedCount, + int? width, + int? height, + int? quality, + int? fillWidth, + int? fillHeight, + int? blur, + string? backgroundColor, + string? foregroundLayer, + BaseItem? item, + ItemImageInfo? imageInfo = null) + { + if (percentPlayed.HasValue) + { + if (percentPlayed.Value <= 0) { - return new ImageInfo - { - Path = info.Path, - ImageIndex = imageIndex, - ImageType = info.Type, - ImageTag = _imageProcessor.GetImageCacheTag(item, info), - Size = length, - BlurHash = blurhash, - Width = width, - Height = height - }; + percentPlayed = null; } - catch (Exception ex) + else if (percentPlayed.Value >= 100) { - _logger.LogError(ex, "Error getting image information for {Path}", info.Path); - return null; + percentPlayed = null; } } - private async Task<ActionResult> GetImageInternal( - Guid itemId, - ImageType imageType, - int? imageIndex, - string? tag, - ImageFormat? format, - int? maxWidth, - int? maxHeight, - double? percentPlayed, - int? unplayedCount, - int? width, - int? height, - int? quality, - int? fillWidth, - int? fillHeight, - int? blur, - string? backgroundColor, - string? foregroundLayer, - BaseItem? item, - ItemImageInfo? imageInfo = null) - { - if (percentPlayed.HasValue) - { - if (percentPlayed.Value <= 0) - { - percentPlayed = null; - } - else if (percentPlayed.Value >= 100) - { - percentPlayed = null; - } - } - - if (percentPlayed.HasValue) - { - unplayedCount = null; - } + if (percentPlayed.HasValue) + { + unplayedCount = null; + } - if (unplayedCount.HasValue - && unplayedCount.Value <= 0) - { - unplayedCount = null; - } + if (unplayedCount.HasValue + && unplayedCount.Value <= 0) + { + unplayedCount = null; + } + if (imageInfo is null) + { + imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0); if (imageInfo is null) { - imageInfo = item?.GetImageInfo(imageType, imageIndex ?? 0); - if (imageInfo is null) - { - return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType)); - } + return NotFound(string.Format(NumberFormatInfo.InvariantInfo, "{0} does not have an image of type {1}", item?.Name, imageType)); } + } - var outputFormats = GetOutputFormats(format); + var outputFormats = GetOutputFormats(format); - TimeSpan? cacheDuration = null; + TimeSpan? cacheDuration = null; - if (!string.IsNullOrEmpty(tag)) - { - cacheDuration = TimeSpan.FromDays(365); - } + if (!string.IsNullOrEmpty(tag)) + { + cacheDuration = TimeSpan.FromDays(365); + } - var responseHeaders = new Dictionary<string, string> + var responseHeaders = new Dictionary<string, string> { { "transferMode.dlna.org", "Interactive" }, { "realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*" } }; - if (!imageInfo.IsLocalFile && item is not null) - { - imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, imageIndex ?? 0).ConfigureAwait(false); - } - - var options = new ImageProcessingOptions - { - Height = height, - ImageIndex = imageIndex ?? 0, - Image = imageInfo, - Item = item, - ItemId = itemId, - MaxHeight = maxHeight, - MaxWidth = maxWidth, - FillHeight = fillHeight, - FillWidth = fillWidth, - Quality = quality ?? 100, - Width = width, - PercentPlayed = percentPlayed ?? 0, - UnplayedCount = unplayedCount, - Blur = blur, - BackgroundColor = backgroundColor, - ForegroundLayer = foregroundLayer, - SupportedOutputFormats = outputFormats - }; - - return await GetImageResult( - options, - cacheDuration, - responseHeaders).ConfigureAwait(false); + if (!imageInfo.IsLocalFile && item is not null) + { + imageInfo = await _libraryManager.ConvertImageToLocal(item, imageInfo, imageIndex ?? 0).ConfigureAwait(false); } - private ImageFormat[] GetOutputFormats(ImageFormat? format) + var options = new ImageProcessingOptions { - if (format.HasValue) - { - return new[] { format.Value }; - } + Height = height, + ImageIndex = imageIndex ?? 0, + Image = imageInfo, + Item = item, + ItemId = itemId, + MaxHeight = maxHeight, + MaxWidth = maxWidth, + FillHeight = fillHeight, + FillWidth = fillWidth, + Quality = quality ?? 100, + Width = width, + PercentPlayed = percentPlayed ?? 0, + UnplayedCount = unplayedCount, + Blur = blur, + BackgroundColor = backgroundColor, + ForegroundLayer = foregroundLayer, + SupportedOutputFormats = outputFormats + }; + + return await GetImageResult( + options, + cacheDuration, + responseHeaders).ConfigureAwait(false); + } - return GetClientSupportedFormats(); + private ImageFormat[] GetOutputFormats(ImageFormat? format) + { + if (format.HasValue) + { + return new[] { format.Value }; } - private ImageFormat[] GetClientSupportedFormats() + return GetClientSupportedFormats(); + } + + private ImageFormat[] GetClientSupportedFormats() + { + var supportedFormats = Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept); + for (var i = 0; i < supportedFormats.Length; i++) { - var supportedFormats = Request.Headers.GetCommaSeparatedValues(HeaderNames.Accept); - for (var i = 0; i < supportedFormats.Length; i++) + // Remove charsets etc. (anything after semi-colon) + var type = supportedFormats[i]; + int index = type.IndexOf(';', StringComparison.Ordinal); + if (index != -1) { - // Remove charsets etc. (anything after semi-colon) - var type = supportedFormats[i]; - int index = type.IndexOf(';', StringComparison.Ordinal); - if (index != -1) - { - supportedFormats[i] = type.Substring(0, index); - } + supportedFormats[i] = type.Substring(0, index); } + } - var acceptParam = Request.Query[HeaderNames.Accept]; + var acceptParam = Request.Query[HeaderNames.Accept]; - var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false); + var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false); - if (!supportsWebP) + if (!supportsWebP) + { + var userAgent = Request.Headers[HeaderNames.UserAgent].ToString(); + if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase) + && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase)) { - var userAgent = Request.Headers[HeaderNames.UserAgent].ToString(); - if (userAgent.Contains("crosswalk", StringComparison.OrdinalIgnoreCase) - && userAgent.Contains("android", StringComparison.OrdinalIgnoreCase)) - { - supportsWebP = true; - } + supportsWebP = true; } + } - var formats = new List<ImageFormat>(4); + var formats = new List<ImageFormat>(4); - if (supportsWebP) - { - formats.Add(ImageFormat.Webp); - } + if (supportsWebP) + { + formats.Add(ImageFormat.Webp); + } - formats.Add(ImageFormat.Jpg); - formats.Add(ImageFormat.Png); + formats.Add(ImageFormat.Jpg); + formats.Add(ImageFormat.Png); - if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true)) - { - formats.Add(ImageFormat.Gif); - } + if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true)) + { + formats.Add(ImageFormat.Gif); + } - return formats.ToArray(); + return formats.ToArray(); + } + + private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll) + { + if (requestAcceptTypes.Contains(format.GetMimeType())) + { + return true; } - private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string? acceptParam, ImageFormat format, bool acceptAll) + if (acceptAll && requestAcceptTypes.Contains("*/*")) { - if (requestAcceptTypes.Contains(format.GetMimeType())) - { - return true; - } + return true; + } - if (acceptAll && requestAcceptTypes.Contains("*/*")) - { - return true; - } + // Review if this should be jpeg, jpg or both for ImageFormat.Jpg + var normalized = format.ToString().ToLowerInvariant(); + return string.Equals(acceptParam, normalized, StringComparison.OrdinalIgnoreCase); + } - // Review if this should be jpeg, jpg or both for ImageFormat.Jpg - var normalized = format.ToString().ToLowerInvariant(); - return string.Equals(acceptParam, normalized, StringComparison.OrdinalIgnoreCase); + private async Task<ActionResult> GetImageResult( + ImageProcessingOptions imageProcessingOptions, + TimeSpan? cacheDuration, + IDictionary<string, string> headers) + { + var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false); + + var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache"); + var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader); + + // if the parsing of the IfModifiedSince header was not successful, disable caching + if (!parsingSuccessful) + { + // disableCaching = true; } - private async Task<ActionResult> GetImageResult( - ImageProcessingOptions imageProcessingOptions, - TimeSpan? cacheDuration, - IDictionary<string, string> headers) + foreach (var (key, value) in headers) { - var (imagePath, imageContentType, dateImageModified) = await _imageProcessor.ProcessImage(imageProcessingOptions).ConfigureAwait(false); + Response.Headers.Add(key, value); + } - var disableCaching = Request.Headers[HeaderNames.CacheControl].Contains("no-cache"); - var parsingSuccessful = DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader); + Response.ContentType = imageContentType ?? MediaTypeNames.Text.Plain; + Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture)); + Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept); - // if the parsing of the IfModifiedSince header was not successful, disable caching - if (!parsingSuccessful) + if (disableCaching) + { + Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate"); + Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate"); + } + else + { + if (cacheDuration.HasValue) { - // disableCaching = true; + Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds); } - - foreach (var (key, value) in headers) + else { - Response.Headers.Add(key, value); + Response.Headers.Add(HeaderNames.CacheControl, "public"); } - Response.ContentType = imageContentType ?? MediaTypeNames.Text.Plain; - Response.Headers.Add(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture)); - Response.Headers.Add(HeaderNames.Vary, HeaderNames.Accept); + Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture)); - if (disableCaching) + // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified + if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue) { - Response.Headers.Add(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate"); - Response.Headers.Add(HeaderNames.Pragma, "no-cache, no-store, must-revalidate"); - } - else - { - if (cacheDuration.HasValue) - { - Response.Headers.Add(HeaderNames.CacheControl, "public, max-age=" + cacheDuration.Value.TotalSeconds); - } - else + if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow) { - Response.Headers.Add(HeaderNames.CacheControl, "public"); - } - - Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", CultureInfo.InvariantCulture)); - - // if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified - if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue) - { - if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow) - { - Response.StatusCode = StatusCodes.Status304NotModified; - return new ContentResult(); - } + Response.StatusCode = StatusCodes.Status304NotModified; + return new ContentResult(); } } - - return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain); } + + return PhysicalFile(imagePath, imageContentType ?? MediaTypeNames.Text.Plain); } } diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index 2e0d3cb99..89592bade 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -16,346 +16,345 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The instant mix controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class InstantMixController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly ILibraryManager _libraryManager; + private readonly IMusicManager _musicManager; + /// <summary> - /// The instant mix controller. + /// Initializes a new instance of the <see cref="InstantMixController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class InstantMixController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public InstantMixController( + IUserManager userManager, + IDtoService dtoService, + IMusicManager musicManager, + ILibraryManager libraryManager) { - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly ILibraryManager _libraryManager; - private readonly IMusicManager _musicManager; - - /// <summary> - /// Initializes a new instance of the <see cref="InstantMixController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public InstantMixController( - IUserManager userManager, - IDtoService dtoService, - IMusicManager musicManager, - ILibraryManager libraryManager) - { - _userManager = userManager; - _dtoService = dtoService; - _musicManager = musicManager; - _libraryManager = libraryManager; - } + _userManager = userManager; + _dtoService = dtoService; + _musicManager = musicManager; + _libraryManager = libraryManager; + } - /// <summary> - /// Creates an instant playlist based on a given song. - /// </summary> - /// <param name="id">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Songs/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var item = _libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given song. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Songs/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong( + [FromRoute, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var item = _libraryManager.GetItemById(id); + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given album. - /// </summary> - /// <param name="id">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Albums/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var album = _libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given album. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Albums/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum( + [FromRoute, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var album = _libraryManager.GetItemById(id); + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given playlist. - /// </summary> - /// <param name="id">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Playlists/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var playlist = (Playlist)_libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given playlist. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Playlists/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist( + [FromRoute, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var playlist = (Playlist)_libraryManager.GetItemById(id); + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given genre. - /// </summary> - /// <param name="name">The genre name.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("MusicGenres/{name}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName( - [FromRoute, Required] string name, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given genre. + /// </summary> + /// <param name="name">The genre name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("MusicGenres/{name}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName( + [FromRoute, Required] string name, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given artist. - /// </summary> - /// <param name="id">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Artists/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var item = _libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given artist. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Artists/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( + [FromRoute, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var item = _libraryManager.GetItemById(id); + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given item. - /// </summary> - /// <param name="id">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Items/{id}/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem( - [FromRoute, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var item = _libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given item. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Items/{id}/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem( + [FromRoute, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var item = _libraryManager.GetItemById(id); + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - /// <summary> - /// Creates an instant playlist based on a given artist. - /// </summary> - /// <param name="id">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Artists/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Obsolete("Use GetInstantMixFromArtists")] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2( - [FromQuery, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - return GetInstantMixFromArtists( - id, - userId, - limit, - fields, - enableImages, - enableUserData, - imageTypeLimit, - enableImageTypes); - } + /// <summary> + /// Creates an instant playlist based on a given artist. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("Artists/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Obsolete("Use GetInstantMixFromArtists")] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2( + [FromQuery, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + return GetInstantMixFromArtists( + id, + userId, + limit, + fields, + enableImages, + enableUserData, + imageTypeLimit, + enableImageTypes); + } - /// <summary> - /// Creates an instant playlist based on a given genre. - /// </summary> - /// <param name="id">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Instant playlist returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("MusicGenres/InstantMix")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( - [FromQuery, Required] Guid id, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var item = _libraryManager.GetItemById(id); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); - return GetResult(items, user, limit, dtoOptions); - } + /// <summary> + /// Creates an instant playlist based on a given genre. + /// </summary> + /// <param name="id">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Instant playlist returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> + [HttpGet("MusicGenres/InstantMix")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( + [FromQuery, Required] Guid id, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var item = _libraryManager.GetItemById(id); + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); + return GetResult(items, user, limit, dtoOptions); + } - private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions) - { - var list = items; + private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions) + { + var list = items; - var totalCount = list.Count; + var totalCount = list.Count; - if (limit.HasValue && limit < list.Count) - { - list = list.GetRange(0, limit.Value); - } + if (limit.HasValue && limit < list.Count) + { + list = list.GetRange(0, limit.Value); + } - var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); + var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user); - var result = new QueryResult<BaseItemDto>( - 0, - totalCount, - returnList); + var result = new QueryResult<BaseItemDto>( + 0, + totalCount, + returnList); - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index b6c5504db..c2ce4e67e 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; @@ -18,257 +17,256 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Item lookup controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class ItemLookupController : BaseJellyfinApiController { + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly ILibraryManager _libraryManager; + private readonly ILogger<ItemLookupController> _logger; + /// <summary> - /// Item lookup controller. + /// Initializes a new instance of the <see cref="ItemLookupController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ItemLookupController : BaseJellyfinApiController + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param> + public ItemLookupController( + IProviderManager providerManager, + IFileSystem fileSystem, + ILibraryManager libraryManager, + ILogger<ItemLookupController> logger) { - private readonly IProviderManager _providerManager; - private readonly IFileSystem _fileSystem; - private readonly ILibraryManager _libraryManager; - private readonly ILogger<ItemLookupController> _logger; + _providerManager = providerManager; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _logger = logger; + } - /// <summary> - /// Initializes a new instance of the <see cref="ItemLookupController"/> class. - /// </summary> - /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{ItemLookupController}"/> interface.</param> - public ItemLookupController( - IProviderManager providerManager, - IFileSystem fileSystem, - ILibraryManager libraryManager, - ILogger<ItemLookupController> logger) + /// <summary> + /// Get the item's external id info. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <response code="200">External id info retrieved.</response> + /// <response code="404">Item not found.</response> + /// <returns>List of external id info.</returns> + [HttpGet("Items/{itemId}/ExternalIdInfos")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - _providerManager = providerManager; - _fileSystem = fileSystem; - _libraryManager = libraryManager; - _logger = logger; + return NotFound(); } - /// <summary> - /// Get the item's external id info. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <response code="200">External id info retrieved.</response> - /// <response code="404">Item not found.</response> - /// <returns>List of external id info.</returns> - [HttpGet("Items/{itemId}/ExternalIdInfos")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } - - return Ok(_providerManager.GetExternalIdInfos(item)); - } + return Ok(_providerManager.GetExternalIdInfos(item)); + } - /// <summary> - /// Get movie remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Movie remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/Movie")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MovieInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get movie remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Movie remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Movie")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMovieRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MovieInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Movie, MovieInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get trailer remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Trailer remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/Trailer")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery<TrailerInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get trailer remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Trailer remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Trailer")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetTrailerRemoteSearchResults([FromBody, Required] RemoteSearchQuery<TrailerInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Trailer, TrailerInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get music video remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Music video remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/MusicVideo")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MusicVideoInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get music video remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music video remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/MusicVideo")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicVideoRemoteSearchResults([FromBody, Required] RemoteSearchQuery<MusicVideoInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicVideo, MusicVideoInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get series remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Series remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/Series")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery<SeriesInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get series remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Series remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Series")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetSeriesRemoteSearchResults([FromBody, Required] RemoteSearchQuery<SeriesInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get box set remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Box set remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/BoxSet")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BoxSetInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get box set remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Box set remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/BoxSet")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBoxSetRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BoxSetInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<BoxSet, BoxSetInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get music artist remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Music artist remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/MusicArtist")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery<ArtistInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get music artist remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music artist remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/MusicArtist")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicArtistRemoteSearchResults([FromBody, Required] RemoteSearchQuery<ArtistInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicArtist, ArtistInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get music album remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Music album remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/MusicAlbum")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery<AlbumInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get music album remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Music album remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/MusicAlbum")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetMusicAlbumRemoteSearchResults([FromBody, Required] RemoteSearchQuery<AlbumInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<MusicAlbum, AlbumInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get person remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Person remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/Person")] - [Authorize(Policy = Policies.RequiresElevation)] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery<PersonLookupInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get person remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Person remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Person")] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetPersonRemoteSearchResults([FromBody, Required] RemoteSearchQuery<PersonLookupInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Person, PersonLookupInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Get book remote search. - /// </summary> - /// <param name="query">Remote search query.</param> - /// <response code="200">Book remote search executed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. - /// </returns> - [HttpPost("Items/RemoteSearch/Book")] - public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BookInfo> query) - { - var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None) - .ConfigureAwait(false); - return Ok(results); - } + /// <summary> + /// Get book remote search. + /// </summary> + /// <param name="query">Remote search query.</param> + /// <response code="200">Book remote search executed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="OkResult"/> containing the list of remote search results. + /// </returns> + [HttpPost("Items/RemoteSearch/Book")] + public async Task<ActionResult<IEnumerable<RemoteSearchResult>>> GetBookRemoteSearchResults([FromBody, Required] RemoteSearchQuery<BookInfo> query) + { + var results = await _providerManager.GetRemoteSearchResults<Book, BookInfo>(query, CancellationToken.None) + .ConfigureAwait(false); + return Ok(results); + } - /// <summary> - /// Applies search criteria to an item and refreshes metadata. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="searchResult">The remote search result.</param> - /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param> - /// <response code="204">Item metadata refreshed.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. - /// The task result contains an <see cref="NoContentResult"/>. - /// </returns> - [HttpPost("Items/RemoteSearch/Apply/{itemId}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ApplySearchCriteria( - [FromRoute, Required] Guid itemId, - [FromBody, Required] RemoteSearchResult searchResult, - [FromQuery] bool replaceAllImages = true) - { - var item = _libraryManager.GetItemById(itemId); - _logger.LogInformation( - "Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}", - item.Id, - item.Name, - searchResult.ProviderIds); + /// <summary> + /// Applies search criteria to an item and refreshes metadata. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="searchResult">The remote search result.</param> + /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param> + /// <response code="204">Item metadata refreshed.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. + /// The task result contains an <see cref="NoContentResult"/>. + /// </returns> + [HttpPost("Items/RemoteSearch/Apply/{itemId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ApplySearchCriteria( + [FromRoute, Required] Guid itemId, + [FromBody, Required] RemoteSearchResult searchResult, + [FromQuery] bool replaceAllImages = true) + { + var item = _libraryManager.GetItemById(itemId); + _logger.LogInformation( + "Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}", + item.Id, + item.Name, + searchResult.ProviderIds); - // Since the refresh process won't erase provider Ids, we need to set this explicitly now. - item.ProviderIds = searchResult.ProviderIds; - await _providerManager.RefreshFullItem( - item, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true, - ReplaceAllImages = replaceAllImages, - SearchResult = searchResult, - RemoveOldMetadata = true - }, - CancellationToken.None).ConfigureAwait(false); + // Since the refresh process won't erase provider Ids, we need to set this explicitly now. + item.ProviderIds = searchResult.ProviderIds; + await _providerManager.RefreshFullItem( + item, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = replaceAllImages, + SearchResult = searchResult, + RemoveOldMetadata = true + }, + CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index 0dc3fbd05..b8f6e91ad 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -9,78 +9,77 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Item Refresh Controller. +/// </summary> +[Route("Items")] +[Authorize(Policy = Policies.RequiresElevation)] +public class ItemRefreshController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + /// <summary> - /// Item Refresh Controller. + /// Initializes a new instance of the <see cref="ItemRefreshController"/> class. /// </summary> - [Route("Items")] - [Authorize(Policy = Policies.RequiresElevation)] - public class ItemRefreshController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + public ItemRefreshController( + ILibraryManager libraryManager, + IProviderManager providerManager, + IFileSystem fileSystem) { - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly IFileSystem _fileSystem; + _libraryManager = libraryManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + } - /// <summary> - /// Initializes a new instance of the <see cref="ItemRefreshController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> - /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> - /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> - public ItemRefreshController( - ILibraryManager libraryManager, - IProviderManager providerManager, - IFileSystem fileSystem) + /// <summary> + /// Refreshes metadata for an item. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param> + /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param> + /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param> + /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param> + /// <response code="204">Item metadata refresh queued.</response> + /// <response code="404">Item to refresh not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("{itemId}/Refresh")] + [Description("Refreshes metadata for an item.")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult RefreshItem( + [FromRoute, Required] Guid itemId, + [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None, + [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None, + [FromQuery] bool replaceAllMetadata = false, + [FromQuery] bool replaceAllImages = false) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - _libraryManager = libraryManager; - _providerManager = providerManager; - _fileSystem = fileSystem; + return NotFound(); } - /// <summary> - /// Refreshes metadata for an item. - /// </summary> - /// <param name="itemId">Item id.</param> - /// <param name="metadataRefreshMode">(Optional) Specifies the metadata refresh mode.</param> - /// <param name="imageRefreshMode">(Optional) Specifies the image refresh mode.</param> - /// <param name="replaceAllMetadata">(Optional) Determines if metadata should be replaced. Only applicable if mode is FullRefresh.</param> - /// <param name="replaceAllImages">(Optional) Determines if images should be replaced. Only applicable if mode is FullRefresh.</param> - /// <response code="204">Item metadata refresh queued.</response> - /// <response code="404">Item to refresh not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> - [HttpPost("{itemId}/Refresh")] - [Description("Refreshes metadata for an item.")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult RefreshItem( - [FromRoute, Required] Guid itemId, - [FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None, - [FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None, - [FromQuery] bool replaceAllMetadata = false, - [FromQuery] bool replaceAllImages = false) + var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + MetadataRefreshMode = metadataRefreshMode, + ImageRefreshMode = imageRefreshMode, + ReplaceAllImages = replaceAllImages, + ReplaceAllMetadata = replaceAllMetadata, + ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh + || imageRefreshMode == MetadataRefreshMode.FullRefresh + || replaceAllImages + || replaceAllMetadata, + IsAutomated = false + }; - var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = metadataRefreshMode, - ImageRefreshMode = imageRefreshMode, - ReplaceAllImages = replaceAllImages, - ReplaceAllMetadata = replaceAllMetadata, - ForceSave = metadataRefreshMode == MetadataRefreshMode.FullRefresh - || imageRefreshMode == MetadataRefreshMode.FullRefresh - || replaceAllImages - || replaceAllMetadata, - IsAutomated = false - }; - - _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); - return NoContent(); - } + _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index af3d779f5..230fbfb2c 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -20,332 +20,332 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Item update controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.RequiresElevation)] +public class ItemUpdateController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly ILocalizationManager _localizationManager; + private readonly IFileSystem _fileSystem; + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemUpdateController"/> class. + /// </summary> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public ItemUpdateController( + IFileSystem fileSystem, + ILibraryManager libraryManager, + IProviderManager providerManager, + ILocalizationManager localizationManager, + IServerConfigurationManager serverConfigurationManager) + { + _libraryManager = libraryManager; + _providerManager = providerManager; + _localizationManager = localizationManager; + _fileSystem = fileSystem; + _serverConfigurationManager = serverConfigurationManager; + } + /// <summary> - /// Item update controller. + /// Updates an item. /// </summary> - [Route("")] - [Authorize(Policy = Policies.RequiresElevation)] - public class ItemUpdateController : BaseJellyfinApiController + /// <param name="itemId">The item id.</param> + /// <param name="request">The new item properties.</param> + /// <response code="204">Item updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("Items/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request) { - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly ILocalizationManager _localizationManager; - private readonly IFileSystem _fileSystem; - private readonly IServerConfigurationManager _serverConfigurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="ItemUpdateController"/> class. - /// </summary> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public ItemUpdateController( - IFileSystem fileSystem, - ILibraryManager libraryManager, - IProviderManager providerManager, - ILocalizationManager localizationManager, - IServerConfigurationManager serverConfigurationManager) - { - _libraryManager = libraryManager; - _providerManager = providerManager; - _localizationManager = localizationManager; - _fileSystem = fileSystem; - _serverConfigurationManager = serverConfigurationManager; + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Updates an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="request">The new item properties.</param> - /// <response code="204">Item updated.</response> - /// <response code="404">Item not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> - [HttpPost("Items/{itemId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + var newLockData = request.LockData ?? false; + var isLockedChanged = item.IsLocked != newLockData; - var newLockData = request.LockData ?? false; - var isLockedChanged = item.IsLocked != newLockData; + var series = item as Series; + var displayOrderChanged = series is not null && !string.Equals( + series.DisplayOrder ?? string.Empty, + request.DisplayOrder ?? string.Empty, + StringComparison.OrdinalIgnoreCase); - var series = item as Series; - var displayOrderChanged = series is not null && !string.Equals( - series.DisplayOrder ?? string.Empty, - request.DisplayOrder ?? string.Empty, - StringComparison.OrdinalIgnoreCase); + // Do this first so that metadata savers can pull the updates from the database. + if (request.People is not null) + { + _libraryManager.UpdatePeople( + item, + request.People.Select(x => new PersonInfo + { + Name = x.Name, + Role = x.Role, + Type = x.Type + }).ToList()); + } - // Do this first so that metadata savers can pull the updates from the database. - if (request.People is not null) - { - _libraryManager.UpdatePeople( - item, - request.People.Select(x => new PersonInfo - { - Name = x.Name, - Role = x.Role, - Type = x.Type - }).ToList()); - } + UpdateItem(request, item); - UpdateItem(request, item); + item.OnMetadataChanged(); - item.OnMetadataChanged(); + await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + if (isLockedChanged && item.IsFolder) + { + var folder = (Folder)item; - if (isLockedChanged && item.IsFolder) + foreach (var child in folder.GetRecursiveChildren()) { - var folder = (Folder)item; + child.IsLocked = newLockData; + await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } + } - foreach (var child in folder.GetRecursiveChildren()) + if (displayOrderChanged) + { + _providerManager.QueueRefresh( + series!.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - child.IsLocked = newLockData; - await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - } - } + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true + }, + RefreshPriority.High); + } - if (displayOrderChanged) - { - _providerManager.QueueRefresh( - series!.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true - }, - RefreshPriority.High); - } + return NoContent(); + } - return NoContent(); - } + /// <summary> + /// Gets metadata editor info for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">Item metadata editor returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpGet("Items/{itemId}/MetadataEditor")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); - /// <summary> - /// Gets metadata editor info for an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <response code="200">Item metadata editor returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>An <see cref="OkResult"/> on success containing the metadata editor, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> - [HttpGet("Items/{itemId}/MetadataEditor")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - - var info = new MetadataEditorInfo - { - ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(), - ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), - Countries = _localizationManager.GetCountries().ToArray(), - Cultures = _localizationManager.GetCultures().ToArray() - }; - - if (!item.IsVirtualItem - && item is not ICollectionFolder - && item is not UserView - && item is not AggregateFolder - && item is not LiveTvChannel - && item is not IItemByName - && item.SourceType == SourceType.Library) + var info = new MetadataEditorInfo + { + ParentalRatingOptions = _localizationManager.GetParentalRatings().ToArray(), + ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(), + Countries = _localizationManager.GetCountries().ToArray(), + Cultures = _localizationManager.GetCultures().ToArray() + }; + + if (!item.IsVirtualItem + && item is not ICollectionFolder + && item is not UserView + && item is not AggregateFolder + && item is not LiveTvChannel + && item is not IItemByName + && item.SourceType == SourceType.Library) + { + var inheritedContentType = _libraryManager.GetInheritedContentType(item); + var configuredContentType = _libraryManager.GetConfiguredContentType(item); + + if (string.IsNullOrWhiteSpace(inheritedContentType) || + !string.IsNullOrWhiteSpace(configuredContentType)) { - var inheritedContentType = _libraryManager.GetInheritedContentType(item); - var configuredContentType = _libraryManager.GetConfiguredContentType(item); + info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); + info.ContentType = configuredContentType; - if (string.IsNullOrWhiteSpace(inheritedContentType) || - !string.IsNullOrWhiteSpace(configuredContentType)) + if (string.IsNullOrWhiteSpace(inheritedContentType) + || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) { - info.ContentTypeOptions = GetContentTypeOptions(true).ToArray(); - info.ContentType = configuredContentType; - - if (string.IsNullOrWhiteSpace(inheritedContentType) - || string.Equals(inheritedContentType, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) - { - info.ContentTypeOptions = info.ContentTypeOptions - .Where(i => string.IsNullOrWhiteSpace(i.Value) - || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } + info.ContentTypeOptions = info.ContentTypeOptions + .Where(i => string.IsNullOrWhiteSpace(i.Value) + || string.Equals(i.Value, CollectionType.TvShows, StringComparison.OrdinalIgnoreCase)) + .ToArray(); } } - - return info; } - /// <summary> - /// Updates an item's content type. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="contentType">The content type of the item.</param> - /// <response code="204">Item content type updated.</response> - /// <response code="404">Item not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> - [HttpPost("Items/{itemId}/ContentType")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType) - { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + return info; + } - var path = item.ContainingFolderPath; + /// <summary> + /// Updates an item's content type. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="contentType">The content type of the item.</param> + /// <response code="204">Item content type updated.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the item could not be found.</returns> + [HttpPost("Items/{itemId}/ContentType")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); + } - var types = _serverConfigurationManager.Configuration.ContentTypes - .Where(i => !string.IsNullOrWhiteSpace(i.Name)) - .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase)) - .ToList(); + var path = item.ContainingFolderPath; - if (!string.IsNullOrWhiteSpace(contentType)) - { - types.Add(new NameValuePair - { - Name = path, - Value = contentType - }); - } + var types = _serverConfigurationManager.Configuration.ContentTypes + .Where(i => !string.IsNullOrWhiteSpace(i.Name)) + .Where(i => !string.Equals(i.Name, path, StringComparison.OrdinalIgnoreCase)) + .ToList(); - _serverConfigurationManager.Configuration.ContentTypes = types.ToArray(); - _serverConfigurationManager.SaveConfiguration(); - return NoContent(); + if (!string.IsNullOrWhiteSpace(contentType)) + { + types.Add(new NameValuePair + { + Name = path, + Value = contentType + }); } - private void UpdateItem(BaseItemDto request, BaseItem item) - { - item.Name = request.Name; - item.ForcedSortName = request.ForcedSortName; + _serverConfigurationManager.Configuration.ContentTypes = types.ToArray(); + _serverConfigurationManager.SaveConfiguration(); + return NoContent(); + } - item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle; + private void UpdateItem(BaseItemDto request, BaseItem item) + { + item.Name = request.Name; + item.ForcedSortName = request.ForcedSortName; - item.CriticRating = request.CriticRating; + item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle; - item.CommunityRating = request.CommunityRating; - item.IndexNumber = request.IndexNumber; - item.ParentIndexNumber = request.ParentIndexNumber; - item.Overview = request.Overview; - item.Genres = request.Genres; + item.CriticRating = request.CriticRating; - if (item is Episode episode) - { - episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber; - episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber; - episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber; - } + item.CommunityRating = request.CommunityRating; + item.IndexNumber = request.IndexNumber; + item.ParentIndexNumber = request.ParentIndexNumber; + item.Overview = request.Overview; + item.Genres = request.Genres; - item.Tags = request.Tags; + if (item is Episode episode) + { + episode.AirsAfterSeasonNumber = request.AirsAfterSeasonNumber; + episode.AirsBeforeEpisodeNumber = request.AirsBeforeEpisodeNumber; + episode.AirsBeforeSeasonNumber = request.AirsBeforeSeasonNumber; + } - if (request.Taglines is not null) - { - item.Tagline = request.Taglines.FirstOrDefault(); - } + item.Tags = request.Tags; - if (request.Studios is not null) - { - item.Studios = request.Studios.Select(x => x.Name).ToArray(); - } + if (request.Taglines is not null) + { + item.Tagline = request.Taglines.FirstOrDefault(); + } - if (request.DateCreated.HasValue) - { - item.DateCreated = NormalizeDateTime(request.DateCreated.Value); - } + if (request.Studios is not null) + { + item.Studios = request.Studios.Select(x => x.Name).ToArray(); + } - item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; - item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; - item.ProductionYear = request.ProductionYear; - item.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating; - item.CustomRating = request.CustomRating; + if (request.DateCreated.HasValue) + { + item.DateCreated = NormalizeDateTime(request.DateCreated.Value); + } - if (request.ProductionLocations is not null) - { - item.ProductionLocations = request.ProductionLocations; - } + item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null; + item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null; + item.ProductionYear = request.ProductionYear; + item.OfficialRating = string.IsNullOrWhiteSpace(request.OfficialRating) ? null : request.OfficialRating; + item.CustomRating = request.CustomRating; - item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode; - item.PreferredMetadataLanguage = request.PreferredMetadataLanguage; + if (request.ProductionLocations is not null) + { + item.ProductionLocations = request.ProductionLocations; + } - if (item is IHasDisplayOrder hasDisplayOrder) - { - hasDisplayOrder.DisplayOrder = request.DisplayOrder; - } + item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode; + item.PreferredMetadataLanguage = request.PreferredMetadataLanguage; - if (item is IHasAspectRatio hasAspectRatio) - { - hasAspectRatio.AspectRatio = request.AspectRatio; - } + if (item is IHasDisplayOrder hasDisplayOrder) + { + hasDisplayOrder.DisplayOrder = request.DisplayOrder; + } + + if (item is IHasAspectRatio hasAspectRatio) + { + hasAspectRatio.AspectRatio = request.AspectRatio; + } - item.IsLocked = request.LockData ?? false; + item.IsLocked = request.LockData ?? false; - if (request.LockedFields is not null) - { - item.LockedFields = request.LockedFields; - } + if (request.LockedFields is not null) + { + item.LockedFields = request.LockedFields; + } - // Only allow this for series. Runtimes for media comes from ffprobe. - if (item is Series) - { - item.RunTimeTicks = request.RunTimeTicks; - } + // Only allow this for series. Runtimes for media comes from ffprobe. + if (item is Series) + { + item.RunTimeTicks = request.RunTimeTicks; + } - foreach (var pair in request.ProviderIds.ToList()) + foreach (var pair in request.ProviderIds.ToList()) + { + if (string.IsNullOrEmpty(pair.Value)) { - if (string.IsNullOrEmpty(pair.Value)) - { - request.ProviderIds.Remove(pair.Key); - } + request.ProviderIds.Remove(pair.Key); } + } - item.ProviderIds = request.ProviderIds; + item.ProviderIds = request.ProviderIds; - if (item is Video video) - { - video.Video3DFormat = request.Video3DFormat; - } + if (item is Video video) + { + video.Video3DFormat = request.Video3DFormat; + } - if (request.AlbumArtists is not null) + if (request.AlbumArtists is not null) + { + if (item is IHasAlbumArtist hasAlbumArtists) { - if (item is IHasAlbumArtist hasAlbumArtists) - { - hasAlbumArtists.AlbumArtists = request - .AlbumArtists - .Select(i => i.Name) - .ToArray(); - } + hasAlbumArtists.AlbumArtists = request + .AlbumArtists + .Select(i => i.Name) + .ToArray(); } + } - if (request.ArtistItems is not null) + if (request.ArtistItems is not null) + { + if (item is IHasArtist hasArtists) { - if (item is IHasArtist hasArtists) - { - hasArtists.Artists = request - .ArtistItems - .Select(i => i.Name) - .ToArray(); - } + hasArtists.Artists = request + .ArtistItems + .Select(i => i.Name) + .ToArray(); } + } - switch (item) - { - case Audio song: - song.Album = request.Album; - break; - case MusicVideo musicVideo: - musicVideo.Album = request.Album; - break; - case Series series: + switch (item) + { + case Audio song: + song.Album = request.Album; + break; + case MusicVideo musicVideo: + musicVideo.Album = request.Album; + break; + case Series series: { series.Status = GetSeriesStatus(request); @@ -357,93 +357,92 @@ namespace Jellyfin.Api.Controllers break; } - } } + } - private SeriesStatus? GetSeriesStatus(BaseItemDto item) + private SeriesStatus? GetSeriesStatus(BaseItemDto item) + { + if (string.IsNullOrEmpty(item.Status)) { - if (string.IsNullOrEmpty(item.Status)) - { - return null; - } - - return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true); + return null; } - private DateTime NormalizeDateTime(DateTime val) - { - return DateTime.SpecifyKind(val, DateTimeKind.Utc); - } + return (SeriesStatus)Enum.Parse(typeof(SeriesStatus), item.Status, true); + } - private List<NameValuePair> GetContentTypeOptions(bool isForItem) - { - var list = new List<NameValuePair>(); + private DateTime NormalizeDateTime(DateTime val) + { + return DateTime.SpecifyKind(val, DateTimeKind.Utc); + } - if (isForItem) - { - list.Add(new NameValuePair - { - Name = "Inherit", - Value = string.Empty - }); - } + private List<NameValuePair> GetContentTypeOptions(bool isForItem) + { + var list = new List<NameValuePair>(); + if (isForItem) + { list.Add(new NameValuePair { - Name = "Movies", - Value = "movies" - }); - list.Add(new NameValuePair - { - Name = "Music", - Value = "music" - }); - list.Add(new NameValuePair - { - Name = "Shows", - Value = "tvshows" + Name = "Inherit", + Value = string.Empty }); + } - if (!isForItem) - { - list.Add(new NameValuePair - { - Name = "Books", - Value = "books" - }); - } + list.Add(new NameValuePair + { + Name = "Movies", + Value = "movies" + }); + list.Add(new NameValuePair + { + Name = "Music", + Value = "music" + }); + list.Add(new NameValuePair + { + Name = "Shows", + Value = "tvshows" + }); + if (!isForItem) + { list.Add(new NameValuePair { - Name = "HomeVideos", - Value = "homevideos" - }); - list.Add(new NameValuePair - { - Name = "MusicVideos", - Value = "musicvideos" - }); - list.Add(new NameValuePair - { - Name = "Photos", - Value = "photos" + Name = "Books", + Value = "books" }); + } - if (!isForItem) - { - list.Add(new NameValuePair - { - Name = "MixedContent", - Value = string.Empty - }); - } + list.Add(new NameValuePair + { + Name = "HomeVideos", + Value = "homevideos" + }); + list.Add(new NameValuePair + { + Name = "MusicVideos", + Value = "musicvideos" + }); + list.Add(new NameValuePair + { + Name = "Photos", + Value = "photos" + }); - foreach (var val in list) + if (!isForItem) + { + list.Add(new NameValuePair { - val.Name = _localizationManager.GetLocalizedString(val.Name); - } + Name = "MixedContent", + Value = string.Empty + }); + } - return list; + foreach (var val in list) + { + val.Name = _localizationManager.GetLocalizedString(val.Name); } + + return list; } } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 717ddc32b..134974dbe 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,7 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; using System.Linq; -using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; @@ -20,854 +19,853 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The items controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class ItemsController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly ILocalizationManager _localization; + private readonly IDtoService _dtoService; + private readonly ILogger<ItemsController> _logger; + private readonly ISessionManager _sessionManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ItemsController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + public ItemsController( + IUserManager userManager, + ILibraryManager libraryManager, + ILocalizationManager localization, + IDtoService dtoService, + ILogger<ItemsController> logger, + ISessionManager sessionManager) + { + _userManager = userManager; + _libraryManager = libraryManager; + _localization = localization; + _dtoService = dtoService; + _logger = logger; + _sessionManager = sessionManager; + } + /// <summary> - /// The items controller. + /// Gets items based on a query. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class ItemsController : BaseJellyfinApiController + /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param> + /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> + /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> + /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> + /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> + /// <param name="hasTrailer">Optional filter by items with trailers.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="parentIndexNumber">Optional filter by parent index number.</param> + /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> + /// <param name="isHd">Optional filter by items that are HD or not.</param> + /// <param name="is4K">Optional filter by items that are 4K or not.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> + /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> + /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> + /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> + /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> + /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> + /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> + /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> + /// <param name="isMovie">Optional filter for live tv movies.</param> + /// <param name="isSeries">Optional filter for live tv series.</param> + /// <param name="isNews">Optional filter for live tv news.</param> + /// <param name="isKids">Optional filter for live tv kids.</param> + /// <param name="isSports">Optional filter for live tv sports.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> + /// <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="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> + /// <param name="searchTerm">Optional. Filter based on a search term.</param> + /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="isPlayed">Optional filter by items that are played, or not.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> + /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> + /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> + /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> + /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> + /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="isLocked">Optional filter by items that are locked.</param> + /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> + /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> + /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> + /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> + /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> + /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> + /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> + /// <param name="is3D">Optional filter by items that are 3D, or not.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> + [HttpGet("Items")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetItems( + [FromQuery] Guid? userId, + [FromQuery] string? maxOfficialRating, + [FromQuery] bool? hasThemeSong, + [FromQuery] bool? hasThemeVideo, + [FromQuery] bool? hasSubtitles, + [FromQuery] bool? hasSpecialFeature, + [FromQuery] bool? hasTrailer, + [FromQuery] Guid? adjacentTo, + [FromQuery] int? parentIndexNumber, + [FromQuery] bool? hasParentalRating, + [FromQuery] bool? isHd, + [FromQuery] bool? is4K, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, + [FromQuery] bool? isMissing, + [FromQuery] bool? isUnaired, + [FromQuery] double? minCommunityRating, + [FromQuery] double? minCriticRating, + [FromQuery] DateTime? minPremiereDate, + [FromQuery] DateTime? minDateLastSaved, + [FromQuery] DateTime? minDateLastSavedForUser, + [FromQuery] DateTime? maxPremiereDate, + [FromQuery] bool? hasOverview, + [FromQuery] bool? hasImdbId, + [FromQuery] bool? hasTmdbId, + [FromQuery] bool? hasTvdbId, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? recursive, + [FromQuery] string? searchTerm, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery] bool? isPlayed, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? person, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, + [FromQuery] string? minOfficialRating, + [FromQuery] bool? isLocked, + [FromQuery] bool? isPlaceHolder, + [FromQuery] bool? hasOfficialRating, + [FromQuery] bool? collapseBoxSetItems, + [FromQuery] int? minWidth, + [FromQuery] int? minHeight, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? is3D, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly ILocalizationManager _localization; - private readonly IDtoService _dtoService; - private readonly ILogger<ItemsController> _logger; - private readonly ISessionManager _sessionManager; - - /// <summary> - /// Initializes a new instance of the <see cref="ItemsController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - public ItemsController( - IUserManager userManager, - ILibraryManager libraryManager, - ILocalizationManager localization, - IDtoService dtoService, - ILogger<ItemsController> logger, - ISessionManager sessionManager) + var isApiKey = User.GetIsApiKey(); + // if api key is used (auth.IsApiKey == true), then `user` will be null throughout this method + var user = !isApiKey && userId.HasValue && !userId.Value.Equals(default) + ? _userManager.GetUserById(userId.Value) + : null; + + // beyond this point, we're either using an api key or we have a valid user + if (!isApiKey && user is null) { - _userManager = userManager; - _libraryManager = libraryManager; - _localization = localization; - _dtoService = dtoService; - _logger = logger; - _sessionManager = sessionManager; + return BadRequest("userId is required"); } - /// <summary> - /// Gets items based on a query. - /// </summary> - /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param> - /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> - /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> - /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> - /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> - /// <param name="hasTrailer">Optional filter by items with trailers.</param> - /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> - /// <param name="parentIndexNumber">Optional filter by parent index number.</param> - /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> - /// <param name="isHd">Optional filter by items that are HD or not.</param> - /// <param name="is4K">Optional filter by items that are 4K or not.</param> - /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> - /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> - /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> - /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> - /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> - /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> - /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> - /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> - /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> - /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> - /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> - /// <param name="isMovie">Optional filter for live tv movies.</param> - /// <param name="isSeries">Optional filter for live tv series.</param> - /// <param name="isNews">Optional filter for live tv news.</param> - /// <param name="isKids">Optional filter for live tv kids.</param> - /// <param name="isSports">Optional filter for live tv sports.</param> - /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> - /// <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="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> - /// <param name="searchTerm">Optional. Filter based on a search term.</param> - /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="isPlayed">Optional filter by items that are played, or not.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> - /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> - /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> - /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> - /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> - /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> - /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> - /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="isLocked">Optional filter by items that are locked.</param> - /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> - /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> - /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> - /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> - /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> - /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> - /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> - /// <param name="is3D">Optional filter by items that are 3D, or not.</param> - /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> - [HttpGet("Items")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetItems( - [FromQuery] Guid? userId, - [FromQuery] string? maxOfficialRating, - [FromQuery] bool? hasThemeSong, - [FromQuery] bool? hasThemeVideo, - [FromQuery] bool? hasSubtitles, - [FromQuery] bool? hasSpecialFeature, - [FromQuery] bool? hasTrailer, - [FromQuery] Guid? adjacentTo, - [FromQuery] int? parentIndexNumber, - [FromQuery] bool? hasParentalRating, - [FromQuery] bool? isHd, - [FromQuery] bool? is4K, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, - [FromQuery] bool? isMissing, - [FromQuery] bool? isUnaired, - [FromQuery] double? minCommunityRating, - [FromQuery] double? minCriticRating, - [FromQuery] DateTime? minPremiereDate, - [FromQuery] DateTime? minDateLastSaved, - [FromQuery] DateTime? minDateLastSavedForUser, - [FromQuery] DateTime? maxPremiereDate, - [FromQuery] bool? hasOverview, - [FromQuery] bool? hasImdbId, - [FromQuery] bool? hasTmdbId, - [FromQuery] bool? hasTvdbId, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? recursive, - [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] bool? isPlayed, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, - [FromQuery] string? minOfficialRating, - [FromQuery] bool? isLocked, - [FromQuery] bool? isPlaceHolder, - [FromQuery] bool? hasOfficialRating, - [FromQuery] bool? collapseBoxSetItems, - [FromQuery] int? minWidth, - [FromQuery] int? minHeight, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? is3D, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool? enableImages = true) + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + if (includeItemTypes.Length == 1 + && (includeItemTypes[0] == BaseItemKind.Playlist + || includeItemTypes[0] == BaseItemKind.BoxSet)) { - var isApiKey = User.GetIsApiKey(); - // if api key is used (auth.IsApiKey == true), then `user` will be null throughout this method - var user = !isApiKey && userId.HasValue && !userId.Value.Equals(default) - ? _userManager.GetUserById(userId.Value) - : null; - - // beyond this point, we're either using an api key or we have a valid user - if (!isApiKey && user is null) - { - return BadRequest("userId is required"); - } + parentId = null; + } + + var item = _libraryManager.GetParentItem(parentId, userId); + QueryResult<BaseItem> result; + + if (item is not Folder folder) + { + folder = _libraryManager.GetUserRootFolder(); + } - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + string? collectionType = null; + if (folder is IHasCollectionType hasCollectionType) + { + collectionType = hasCollectionType.CollectionType; + } + + if (string.Equals(collectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) + { + recursive = true; + includeItemTypes = new[] { BaseItemKind.Playlist }; + } + + if (item is not UserRootFolder + // api keys can always access all folders + && !isApiKey + // check the item is visible for the user + && !item.IsVisible(user)) + { + _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}", user!.Username, item.Name); + return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); + } + + if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder) + { + var query = new InternalItemsQuery(user) + { + IsPlayed = isPlayed, + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + Recursive = recursive ?? false, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + IsFavorite = isFavorite, + Limit = limit, + StartIndex = startIndex, + IsMissing = isMissing, + IsUnaired = isUnaired, + CollapseBoxSetItems = collapseBoxSetItems, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + HasImdbId = hasImdbId, + IsPlaceHolder = isPlaceHolder, + IsLocked = isLocked, + MinWidth = minWidth, + MinHeight = minHeight, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + Is3D = is3D, + HasTvdbId = hasTvdbId, + HasTmdbId = hasTmdbId, + IsMovie = isMovie, + IsSeries = isSeries, + IsNews = isNews, + IsKids = isKids, + IsSports = isSports, + HasOverview = hasOverview, + HasOfficialRating = hasOfficialRating, + HasParentalRating = hasParentalRating, + HasSpecialFeature = hasSpecialFeature, + HasSubtitles = hasSubtitles, + HasThemeSong = hasThemeSong, + HasThemeVideo = hasThemeVideo, + HasTrailer = hasTrailer, + IsHD = isHd, + Is4K = is4K, + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + ArtistIds = artistIds, + AlbumArtistIds = albumArtistIds, + ContributingArtistIds = contributingArtistIds, + GenreIds = genreIds, + StudioIds = studioIds, + Person = person, + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, + ImageTypes = imageTypes, + VideoTypes = videoTypes, + AdjacentTo = adjacentTo, + ItemIds = ids, + MinCommunityRating = minCommunityRating, + MinCriticRating = minCriticRating, + ParentId = parentId ?? Guid.Empty, + ParentIndexNumber = parentIndexNumber, + EnableTotalRecordCount = enableTotalRecordCount, + ExcludeItemIds = excludeItemIds, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), + MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), + MinPremiereDate = minPremiereDate?.ToUniversalTime(), + MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), + }; - if (includeItemTypes.Length == 1 - && (includeItemTypes[0] == BaseItemKind.Playlist - || includeItemTypes[0] == BaseItemKind.BoxSet)) + if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) { - parentId = null; + query.CollapseBoxSetItems = false; } - var item = _libraryManager.GetParentItem(parentId, userId); - QueryResult<BaseItem> result; + foreach (var filter in filters) + { + switch (filter) + { + case ItemFilter.Dislikes: + query.IsLiked = false; + break; + case ItemFilter.IsFavorite: + query.IsFavorite = true; + break; + case ItemFilter.IsFavoriteOrLikes: + query.IsFavoriteOrLiked = true; + break; + case ItemFilter.IsFolder: + query.IsFolder = true; + break; + case ItemFilter.IsNotFolder: + query.IsFolder = false; + break; + case ItemFilter.IsPlayed: + query.IsPlayed = true; + break; + case ItemFilter.IsResumable: + query.IsResumable = true; + break; + case ItemFilter.IsUnplayed: + query.IsPlayed = false; + break; + case ItemFilter.Likes: + query.IsLiked = true; + break; + } + } - if (item is not Folder folder) + // Filter by Series Status + if (seriesStatus.Length != 0) { - folder = _libraryManager.GetUserRootFolder(); + query.SeriesStatuses = seriesStatus; } - string? collectionType = null; - if (folder is IHasCollectionType hasCollectionType) + // ExcludeLocationTypes + if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) { - collectionType = hasCollectionType.CollectionType; + query.IsVirtualItem = false; } - if (string.Equals(collectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) + if (locationTypes.Length > 0 && locationTypes.Length < 4) { - recursive = true; - includeItemTypes = new[] { BaseItemKind.Playlist }; + query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); } - if (item is not UserRootFolder - // api keys can always access all folders - && !isApiKey - // check the item is visible for the user - && !item.IsVisible(user)) + // Min official rating + if (!string.IsNullOrWhiteSpace(minOfficialRating)) { - _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}", user!.Username, item.Name); - return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); + query.MinParentalRating = _localization.GetRatingLevel(minOfficialRating); } - if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder) + // Max official rating + if (!string.IsNullOrWhiteSpace(maxOfficialRating)) { - var query = new InternalItemsQuery(user) - { - IsPlayed = isPlayed, - MediaTypes = mediaTypes, - IncludeItemTypes = includeItemTypes, - ExcludeItemTypes = excludeItemTypes, - Recursive = recursive ?? false, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - IsFavorite = isFavorite, - Limit = limit, - StartIndex = startIndex, - IsMissing = isMissing, - IsUnaired = isUnaired, - CollapseBoxSetItems = collapseBoxSetItems, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - HasImdbId = hasImdbId, - IsPlaceHolder = isPlaceHolder, - IsLocked = isLocked, - MinWidth = minWidth, - MinHeight = minHeight, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - Is3D = is3D, - HasTvdbId = hasTvdbId, - HasTmdbId = hasTmdbId, - IsMovie = isMovie, - IsSeries = isSeries, - IsNews = isNews, - IsKids = isKids, - IsSports = isSports, - HasOverview = hasOverview, - HasOfficialRating = hasOfficialRating, - HasParentalRating = hasParentalRating, - HasSpecialFeature = hasSpecialFeature, - HasSubtitles = hasSubtitles, - HasThemeSong = hasThemeSong, - HasThemeVideo = hasThemeVideo, - HasTrailer = hasTrailer, - IsHD = isHd, - Is4K = is4K, - Tags = tags, - OfficialRatings = officialRatings, - Genres = genres, - ArtistIds = artistIds, - AlbumArtistIds = albumArtistIds, - ContributingArtistIds = contributingArtistIds, - GenreIds = genreIds, - StudioIds = studioIds, - Person = person, - PersonIds = personIds, - PersonTypes = personTypes, - Years = years, - ImageTypes = imageTypes, - VideoTypes = videoTypes, - AdjacentTo = adjacentTo, - ItemIds = ids, - MinCommunityRating = minCommunityRating, - MinCriticRating = minCriticRating, - ParentId = parentId ?? Guid.Empty, - ParentIndexNumber = parentIndexNumber, - EnableTotalRecordCount = enableTotalRecordCount, - ExcludeItemIds = excludeItemIds, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), - MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), - MinPremiereDate = minPremiereDate?.ToUniversalTime(), - MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), - }; - - if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) - { - query.CollapseBoxSetItems = false; - } + query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating); + } - foreach (var filter in filters) + // Artists + if (artists.Length != 0) + { + query.ArtistIds = artists.Select(i => { - switch (filter) + try { - case ItemFilter.Dislikes: - query.IsLiked = false; - break; - case ItemFilter.IsFavorite: - query.IsFavorite = true; - break; - case ItemFilter.IsFavoriteOrLikes: - query.IsFavoriteOrLiked = true; - break; - case ItemFilter.IsFolder: - query.IsFolder = true; - break; - case ItemFilter.IsNotFolder: - query.IsFolder = false; - break; - case ItemFilter.IsPlayed: - query.IsPlayed = true; - break; - case ItemFilter.IsResumable: - query.IsResumable = true; - break; - case ItemFilter.IsUnplayed: - query.IsPlayed = false; - break; - case ItemFilter.Likes: - query.IsLiked = true; - break; + return _libraryManager.GetArtist(i, new DtoOptions(false)); } - } - - // Filter by Series Status - if (seriesStatus.Length != 0) - { - query.SeriesStatuses = seriesStatus; - } - - // ExcludeLocationTypes - if (excludeLocationTypes.Any(t => t == LocationType.Virtual)) - { - query.IsVirtualItem = false; - } - - if (locationTypes.Length > 0 && locationTypes.Length < 4) - { - query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); - } - - // Min official rating - if (!string.IsNullOrWhiteSpace(minOfficialRating)) - { - query.MinParentalRating = _localization.GetRatingLevel(minOfficialRating); - } - - // Max official rating - if (!string.IsNullOrWhiteSpace(maxOfficialRating)) - { - query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating); - } - - // Artists - if (artists.Length != 0) - { - query.ArtistIds = artists.Select(i => + catch { - try - { - return _libraryManager.GetArtist(i, new DtoOptions(false)); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } + return null; + } + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); + } - // ExcludeArtistIds - if (excludeArtistIds.Length != 0) - { - query.ExcludeArtistIds = excludeArtistIds; - } + // ExcludeArtistIds + if (excludeArtistIds.Length != 0) + { + query.ExcludeArtistIds = excludeArtistIds; + } - if (albumIds.Length != 0) - { - query.AlbumIds = albumIds; - } + if (albumIds.Length != 0) + { + query.AlbumIds = albumIds; + } - // Albums - if (albums.Length != 0) + // Albums + if (albums.Length != 0) + { + query.AlbumIds = albums.SelectMany(i => { - query.AlbumIds = albums.SelectMany(i => - { - return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 }); - }).ToArray(); - } + return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 }); + }).ToArray(); + } - // Studios - if (studios.Length != 0) + // Studios + if (studios.Length != 0) + { + query.StudioIds = studios.Select(i => { - query.StudioIds = studios.Select(i => + try { - try - { - return _libraryManager.GetStudio(i); - } - catch - { - return null; - } - }).Where(i => i is not null).Select(i => i!.Id).ToArray(); - } - - // Apply default sorting if none requested - if (query.OrderBy.Count == 0) - { - // Albums by artist - if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) + return _libraryManager.GetStudio(i); + } + catch { - query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) }; + return null; } - } - - result = folder.GetItems(query); + }).Where(i => i is not null).Select(i => i!.Id).ToArray(); } - else + + // Apply default sorting if none requested + if (query.OrderBy.Count == 0) { - var itemsArray = folder.GetChildren(user, true); - result = new QueryResult<BaseItem>(itemsArray); + // Albums by artist + if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) + { + query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) }; + } } - return new QueryResult<BaseItemDto>( - startIndex, - result.TotalRecordCount, - _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user)); + result = folder.GetItems(query); } - - /// <summary> - /// Gets items based on a query. - /// </summary> - /// <param name="userId">The user id supplied as query parameter.</param> - /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> - /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> - /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> - /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> - /// <param name="hasTrailer">Optional filter by items with trailers.</param> - /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> - /// <param name="parentIndexNumber">Optional filter by parent index number.</param> - /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> - /// <param name="isHd">Optional filter by items that are HD or not.</param> - /// <param name="is4K">Optional filter by items that are 4K or not.</param> - /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> - /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> - /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> - /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> - /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> - /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> - /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> - /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> - /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> - /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> - /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> - /// <param name="isMovie">Optional filter for live tv movies.</param> - /// <param name="isSeries">Optional filter for live tv series.</param> - /// <param name="isNews">Optional filter for live tv news.</param> - /// <param name="isKids">Optional filter for live tv kids.</param> - /// <param name="isSports">Optional filter for live tv sports.</param> - /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> - /// <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="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> - /// <param name="searchTerm">Optional. Filter based on a search term.</param> - /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="isPlayed">Optional filter by items that are played, or not.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> - /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> - /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> - /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> - /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> - /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> - /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> - /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="isLocked">Optional filter by items that are locked.</param> - /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> - /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> - /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> - /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> - /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> - /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> - /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> - /// <param name="is3D">Optional filter by items that are 3D, or not.</param> - /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> - [HttpGet("Users/{userId}/Items")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId( - [FromRoute] Guid userId, - [FromQuery] string? maxOfficialRating, - [FromQuery] bool? hasThemeSong, - [FromQuery] bool? hasThemeVideo, - [FromQuery] bool? hasSubtitles, - [FromQuery] bool? hasSpecialFeature, - [FromQuery] bool? hasTrailer, - [FromQuery] Guid? adjacentTo, - [FromQuery] int? parentIndexNumber, - [FromQuery] bool? hasParentalRating, - [FromQuery] bool? isHd, - [FromQuery] bool? is4K, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, - [FromQuery] bool? isMissing, - [FromQuery] bool? isUnaired, - [FromQuery] double? minCommunityRating, - [FromQuery] double? minCriticRating, - [FromQuery] DateTime? minPremiereDate, - [FromQuery] DateTime? minDateLastSaved, - [FromQuery] DateTime? minDateLastSavedForUser, - [FromQuery] DateTime? maxPremiereDate, - [FromQuery] bool? hasOverview, - [FromQuery] bool? hasImdbId, - [FromQuery] bool? hasTmdbId, - [FromQuery] bool? hasTvdbId, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? recursive, - [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] bool? isPlayed, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, - [FromQuery] string? minOfficialRating, - [FromQuery] bool? isLocked, - [FromQuery] bool? isPlaceHolder, - [FromQuery] bool? hasOfficialRating, - [FromQuery] bool? collapseBoxSetItems, - [FromQuery] int? minWidth, - [FromQuery] int? minHeight, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? is3D, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool? enableImages = true) + else { - return GetItems( - userId, - maxOfficialRating, - hasThemeSong, - hasThemeVideo, - hasSubtitles, - hasSpecialFeature, - hasTrailer, - adjacentTo, - parentIndexNumber, - hasParentalRating, - isHd, - is4K, - locationTypes, - excludeLocationTypes, - isMissing, - isUnaired, - minCommunityRating, - minCriticRating, - minPremiereDate, - minDateLastSaved, - minDateLastSavedForUser, - maxPremiereDate, - hasOverview, - hasImdbId, - hasTmdbId, - hasTvdbId, - isMovie, - isSeries, - isNews, - isKids, - isSports, - excludeItemIds, - startIndex, - limit, - recursive, - searchTerm, - sortOrder, - parentId, - fields, - excludeItemTypes, - includeItemTypes, - filters, - isFavorite, - mediaTypes, - imageTypes, - sortBy, - isPlayed, - genres, - officialRatings, - tags, - years, - enableUserData, - imageTypeLimit, - enableImageTypes, - person, - personIds, - personTypes, - studios, - artists, - excludeArtistIds, - artistIds, - albumArtistIds, - contributingArtistIds, - albums, - albumIds, - ids, - videoTypes, - minOfficialRating, - isLocked, - isPlaceHolder, - hasOfficialRating, - collapseBoxSetItems, - minWidth, - minHeight, - maxWidth, - maxHeight, - is3D, - seriesStatus, - nameStartsWithOrGreater, - nameStartsWith, - nameLessThan, - studioIds, - genreIds, - enableTotalRecordCount, - enableImages); + var itemsArray = folder.GetChildren(user, true); + result = new QueryResult<BaseItem>(itemsArray); } - /// <summary> - /// Gets items based on a query. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="startIndex">The start index.</param> - /// <param name="limit">The item limit.</param> - /// <param name="searchTerm">The search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> - /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> - /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param> - /// <response code="200">Items returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns> - [HttpGet("Users/{userId}/Items/Resume")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetResumeItems( - [FromRoute, Required] Guid userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool? enableImages = true, - [FromQuery] bool excludeActiveSessions = false) - { - var user = _userManager.GetUserById(userId); - var parentIdGuid = parentId ?? Guid.Empty; - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - var ancestorIds = Array.Empty<Guid>(); + return new QueryResult<BaseItemDto>( + startIndex, + result.TotalRecordCount, + _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user)); + } - var excludeFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes); - if (parentIdGuid.Equals(default) && excludeFolderIds.Length > 0) - { - ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true) - .Where(i => i is Folder) - .Where(i => !excludeFolderIds.Contains(i.Id)) - .Select(i => i.Id) - .ToArray(); - } + /// <summary> + /// Gets items based on a query. + /// </summary> + /// <param name="userId">The user id supplied as query parameter.</param> + /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> + /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> + /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> + /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> + /// <param name="hasTrailer">Optional filter by items with trailers.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="parentIndexNumber">Optional filter by parent index number.</param> + /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> + /// <param name="isHd">Optional filter by items that are HD or not.</param> + /// <param name="is4K">Optional filter by items that are 4K or not.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> + /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> + /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> + /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> + /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> + /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> + /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> + /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> + /// <param name="isMovie">Optional filter for live tv movies.</param> + /// <param name="isSeries">Optional filter for live tv series.</param> + /// <param name="isNews">Optional filter for live tv news.</param> + /// <param name="isKids">Optional filter for live tv kids.</param> + /// <param name="isSports">Optional filter for live tv sports.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> + /// <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="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> + /// <param name="searchTerm">Optional. Filter based on a search term.</param> + /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="isPlayed">Optional filter by items that are played, or not.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> + /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> + /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> + /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> + /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> + /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="isLocked">Optional filter by items that are locked.</param> + /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> + /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> + /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> + /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> + /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> + /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> + /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> + /// <param name="is3D">Optional filter by items that are 3D, or not.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns> + [HttpGet("Users/{userId}/Items")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId( + [FromRoute] Guid userId, + [FromQuery] string? maxOfficialRating, + [FromQuery] bool? hasThemeSong, + [FromQuery] bool? hasThemeVideo, + [FromQuery] bool? hasSubtitles, + [FromQuery] bool? hasSpecialFeature, + [FromQuery] bool? hasTrailer, + [FromQuery] Guid? adjacentTo, + [FromQuery] int? parentIndexNumber, + [FromQuery] bool? hasParentalRating, + [FromQuery] bool? isHd, + [FromQuery] bool? is4K, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, + [FromQuery] bool? isMissing, + [FromQuery] bool? isUnaired, + [FromQuery] double? minCommunityRating, + [FromQuery] double? minCriticRating, + [FromQuery] DateTime? minPremiereDate, + [FromQuery] DateTime? minDateLastSaved, + [FromQuery] DateTime? minDateLastSavedForUser, + [FromQuery] DateTime? maxPremiereDate, + [FromQuery] bool? hasOverview, + [FromQuery] bool? hasImdbId, + [FromQuery] bool? hasTmdbId, + [FromQuery] bool? hasTvdbId, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? recursive, + [FromQuery] string? searchTerm, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery] bool? isPlayed, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? person, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, + [FromQuery] string? minOfficialRating, + [FromQuery] bool? isLocked, + [FromQuery] bool? isPlaceHolder, + [FromQuery] bool? hasOfficialRating, + [FromQuery] bool? collapseBoxSetItems, + [FromQuery] int? minWidth, + [FromQuery] int? minHeight, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? is3D, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true) + { + return GetItems( + userId, + maxOfficialRating, + hasThemeSong, + hasThemeVideo, + hasSubtitles, + hasSpecialFeature, + hasTrailer, + adjacentTo, + parentIndexNumber, + hasParentalRating, + isHd, + is4K, + locationTypes, + excludeLocationTypes, + isMissing, + isUnaired, + minCommunityRating, + minCriticRating, + minPremiereDate, + minDateLastSaved, + minDateLastSavedForUser, + maxPremiereDate, + hasOverview, + hasImdbId, + hasTmdbId, + hasTvdbId, + isMovie, + isSeries, + isNews, + isKids, + isSports, + excludeItemIds, + startIndex, + limit, + recursive, + searchTerm, + sortOrder, + parentId, + fields, + excludeItemTypes, + includeItemTypes, + filters, + isFavorite, + mediaTypes, + imageTypes, + sortBy, + isPlayed, + genres, + officialRatings, + tags, + years, + enableUserData, + imageTypeLimit, + enableImageTypes, + person, + personIds, + personTypes, + studios, + artists, + excludeArtistIds, + artistIds, + albumArtistIds, + contributingArtistIds, + albums, + albumIds, + ids, + videoTypes, + minOfficialRating, + isLocked, + isPlaceHolder, + hasOfficialRating, + collapseBoxSetItems, + minWidth, + minHeight, + maxWidth, + maxHeight, + is3D, + seriesStatus, + nameStartsWithOrGreater, + nameStartsWith, + nameLessThan, + studioIds, + genreIds, + enableTotalRecordCount, + enableImages); + } - var excludeItemIds = Array.Empty<Guid>(); - if (excludeActiveSessions) - { - excludeItemIds = _sessionManager.Sessions - .Where(s => s.UserId.Equals(userId) && s.NowPlayingItem is not null) - .Select(s => s.NowPlayingItem.Id) - .ToArray(); - } + /// <summary> + /// Gets items based on a query. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="startIndex">The start index.</param> + /// <param name="limit">The item limit.</param> + /// <param name="searchTerm">The search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param> + /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="excludeActiveSessions">Optional. Whether to exclude the currently active sessions.</param> + /// <response code="200">Items returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns> + [HttpGet("Users/{userId}/Items/Resume")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetResumeItems( + [FromRoute, Required] Guid userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true, + [FromQuery] bool excludeActiveSessions = false) + { + var user = _userManager.GetUserById(userId); + var parentIdGuid = parentId ?? Guid.Empty; + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) - { - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, - IsResumable = true, - StartIndex = startIndex, - Limit = limit, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = dtoOptions, - MediaTypes = mediaTypes, - IsVirtualItem = false, - CollapseBoxSetItems = false, - EnableTotalRecordCount = enableTotalRecordCount, - AncestorIds = ancestorIds, - IncludeItemTypes = includeItemTypes, - ExcludeItemTypes = excludeItemTypes, - SearchTerm = searchTerm, - ExcludeItemIds = excludeItemIds - }); + var ancestorIds = Array.Empty<Guid>(); - var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user); + var excludeFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes); + if (parentIdGuid.Equals(default) && excludeFolderIds.Length > 0) + { + ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true) + .Where(i => i is Folder) + .Where(i => !excludeFolderIds.Contains(i.Id)) + .Select(i => i.Id) + .ToArray(); + } - return new QueryResult<BaseItemDto>( - startIndex, - itemsResult.TotalRecordCount, - returnItems); + var excludeItemIds = Array.Empty<Guid>(); + if (excludeActiveSessions) + { + excludeItemIds = _sessionManager.Sessions + .Where(s => s.UserId.Equals(userId) && s.NowPlayingItem is not null) + .Select(s => s.NowPlayingItem.Id) + .ToArray(); } + + var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) + { + OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, + IsResumable = true, + StartIndex = startIndex, + Limit = limit, + ParentId = parentIdGuid, + Recursive = true, + DtoOptions = dtoOptions, + MediaTypes = mediaTypes, + IsVirtualItem = false, + CollapseBoxSetItems = false, + EnableTotalRecordCount = enableTotalRecordCount, + AncestorIds = ancestorIds, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + SearchTerm = searchTerm, + ExcludeItemIds = excludeItemIds + }); + + var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user); + + return new QueryResult<BaseItemDto>( + startIndex, + itemsResult.TotalRecordCount, + returnItems); } } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 196d509fb..830f84849 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -4,8 +4,6 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; using System.Linq; -using System.Net; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; @@ -37,936 +35,935 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Library Controller. +/// </summary> +[Route("")] +public class LibraryController : BaseJellyfinApiController { + private readonly IProviderManager _providerManager; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly IActivityManager _activityManager; + private readonly ILocalizationManager _localization; + private readonly ILibraryMonitor _libraryMonitor; + private readonly ILogger<LibraryController> _logger; + private readonly IServerConfigurationManager _serverConfigurationManager; + /// <summary> - /// Library Controller. + /// Initializes a new instance of the <see cref="LibraryController"/> class. /// </summary> - [Route("")] - public class LibraryController : BaseJellyfinApiController + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{LibraryController}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public LibraryController( + IProviderManager providerManager, + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService, + IActivityManager activityManager, + ILocalizationManager localization, + ILibraryMonitor libraryMonitor, + ILogger<LibraryController> logger, + IServerConfigurationManager serverConfigurationManager) { - private readonly IProviderManager _providerManager; - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly IActivityManager _activityManager; - private readonly ILocalizationManager _localization; - private readonly ILibraryMonitor _libraryMonitor; - private readonly ILogger<LibraryController> _logger; - private readonly IServerConfigurationManager _serverConfigurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="LibraryController"/> class. - /// </summary> - /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> - /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> - /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{LibraryController}"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public LibraryController( - IProviderManager providerManager, - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService, - IActivityManager activityManager, - ILocalizationManager localization, - ILibraryMonitor libraryMonitor, - ILogger<LibraryController> logger, - IServerConfigurationManager serverConfigurationManager) - { - _providerManager = providerManager; - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - _activityManager = activityManager; - _localization = localization; - _libraryMonitor = libraryMonitor; - _logger = logger; - _serverConfigurationManager = serverConfigurationManager; - } - - /// <summary> - /// Get the original file of an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <response code="200">File stream returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="FileStreamResult"/> with the original file.</returns> - [HttpGet("Items/{itemId}/File")] - [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); - if (item is null) - { - return NotFound(); - } - - return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true); - } + _providerManager = providerManager; + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + _activityManager = activityManager; + _localization = localization; + _libraryMonitor = libraryMonitor; + _logger = logger; + _serverConfigurationManager = serverConfigurationManager; + } - /// <summary> - /// Gets critic review for an item. - /// </summary> - /// <response code="200">Critic reviews returned.</response> - /// <returns>The list of critic reviews.</returns> - [HttpGet("Items/{itemId}/CriticReviews")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [Obsolete("This endpoint is obsolete.")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetCriticReviews() + /// <summary> + /// Get the original file of an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">File stream returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="FileStreamResult"/> with the original file.</returns> + [HttpGet("Items/{itemId}/File")] + [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); + if (item is null) { - return new QueryResult<BaseItemDto>(); + return NotFound(); } - /// <summary> - /// Get theme songs for an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> - /// <response code="200">Theme songs returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>The item theme songs.</returns> - [HttpGet("Items/{itemId}/ThemeSongs")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<ThemeMediaResult> GetThemeSongs( - [FromRoute, Required] Guid itemId, - [FromQuery] Guid? userId, - [FromQuery] bool inheritFromParent = false) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var item = itemId.Equals(default) - ? (userId is null || userId.Value.Equals(default) - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); - - if (item is null) - { - return NotFound("Item not found."); - } + return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), true); + } - IEnumerable<BaseItem> themeItems; + /// <summary> + /// Gets critic review for an item. + /// </summary> + /// <response code="200">Critic reviews returned.</response> + /// <returns>The list of critic reviews.</returns> + [HttpGet("Items/{itemId}/CriticReviews")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [Obsolete("This endpoint is obsolete.")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetCriticReviews() + { + return new QueryResult<BaseItemDto>(); + } - while (true) - { - themeItems = item.GetThemeSongs(); + /// <summary> + /// Get theme songs for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <response code="200">Theme songs returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The item theme songs.</returns> + [HttpGet("Items/{itemId}/ThemeSongs")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<ThemeMediaResult> GetThemeSongs( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] bool inheritFromParent = false) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - if (themeItems.Any() || !inheritFromParent) - { - break; - } + var item = itemId.Equals(default) + ? (userId is null || userId.Value.Equals(default) + ? _libraryManager.RootFolder + : _libraryManager.GetUserRootFolder()) + : _libraryManager.GetItemById(itemId); - var parent = item.GetParent(); - if (parent is null) - { - break; - } + if (item is null) + { + return NotFound("Item not found."); + } - item = parent; - } + IEnumerable<BaseItem> themeItems; - var dtoOptions = new DtoOptions().AddClientFields(User); - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); + while (true) + { + themeItems = item.GetThemeSongs(); - return new ThemeMediaResult + if (themeItems.Any() || !inheritFromParent) { - Items = items, - TotalRecordCount = items.Length, - OwnerId = item.Id - }; - } - - /// <summary> - /// Get theme videos for an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> - /// <response code="200">Theme videos returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>The item theme videos.</returns> - [HttpGet("Items/{itemId}/ThemeVideos")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<ThemeMediaResult> GetThemeVideos( - [FromRoute, Required] Guid itemId, - [FromQuery] Guid? userId, - [FromQuery] bool inheritFromParent = false) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var item = itemId.Equals(default) - ? (userId is null || userId.Value.Equals(default) - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); - - if (item is null) + break; + } + + var parent = item.GetParent(); + if (parent is null) { - return NotFound("Item not found."); + break; } - IEnumerable<BaseItem> themeItems; + item = parent; + } - while (true) - { - themeItems = item.GetThemeVideos(); + var dtoOptions = new DtoOptions().AddClientFields(User); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); - if (themeItems.Any() || !inheritFromParent) - { - break; - } + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } - var parent = item.GetParent(); - if (parent is null) - { - break; - } + /// <summary> + /// Get theme videos for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <response code="200">Theme videos returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The item theme videos.</returns> + [HttpGet("Items/{itemId}/ThemeVideos")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<ThemeMediaResult> GetThemeVideos( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] bool inheritFromParent = false) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - item = parent; - } + var item = itemId.Equals(default) + ? (userId is null || userId.Value.Equals(default) + ? _libraryManager.RootFolder + : _libraryManager.GetUserRootFolder()) + : _libraryManager.GetItemById(itemId); - var dtoOptions = new DtoOptions().AddClientFields(User); - var items = themeItems - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) - .ToArray(); + if (item is null) + { + return NotFound("Item not found."); + } - return new ThemeMediaResult - { - Items = items, - TotalRecordCount = items.Length, - OwnerId = item.Id - }; - } - - /// <summary> - /// Get theme songs and videos for an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> - /// <response code="200">Theme songs and videos returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>The item theme videos.</returns> - [HttpGet("Items/{itemId}/ThemeMedia")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<AllThemeMediaResult> GetThemeMedia( - [FromRoute, Required] Guid itemId, - [FromQuery] Guid? userId, - [FromQuery] bool inheritFromParent = false) - { - var themeSongs = GetThemeSongs( - itemId, - userId, - inheritFromParent); - - var themeVideos = GetThemeVideos( - itemId, - userId, - inheritFromParent); - - return new AllThemeMediaResult - { - ThemeSongsResult = themeSongs?.Value, - ThemeVideosResult = themeVideos?.Value, - SoundtrackSongsResult = new ThemeMediaResult() - }; - } - - /// <summary> - /// Starts a library scan. - /// </summary> - /// <response code="204">Library scan started.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Library/Refresh")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RefreshLibrary() - { - try + IEnumerable<BaseItem> themeItems; + + while (true) + { + themeItems = item.GetThemeVideos(); + + if (themeItems.Any() || !inheritFromParent) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + break; } - catch (Exception ex) + + var parent = item.GetParent(); + if (parent is null) { - _logger.LogError(ex, "Error refreshing library"); + break; } + item = parent; + } + + var dtoOptions = new DtoOptions().AddClientFields(User); + var items = themeItems + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)) + .ToArray(); + + return new ThemeMediaResult + { + Items = items, + TotalRecordCount = items.Length, + OwnerId = item.Id + }; + } + + /// <summary> + /// Get theme songs and videos for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <response code="200">Theme songs and videos returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>The item theme videos.</returns> + [HttpGet("Items/{itemId}/ThemeMedia")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<AllThemeMediaResult> GetThemeMedia( + [FromRoute, Required] Guid itemId, + [FromQuery] Guid? userId, + [FromQuery] bool inheritFromParent = false) + { + var themeSongs = GetThemeSongs( + itemId, + userId, + inheritFromParent); + + var themeVideos = GetThemeVideos( + itemId, + userId, + inheritFromParent); + + return new AllThemeMediaResult + { + ThemeSongsResult = themeSongs?.Value, + ThemeVideosResult = themeVideos?.Value, + SoundtrackSongsResult = new ThemeMediaResult() + }; + } + + /// <summary> + /// Starts a library scan. + /// </summary> + /// <response code="204">Library scan started.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Refresh")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RefreshLibrary() + { + try + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing library"); + } + + return NoContent(); + } + + /// <summary> + /// Deletes an item from the library and filesystem. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="204">Item deleted.</response> + /// <response code="401">Unauthorized access.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Items/{itemId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public ActionResult DeleteItem(Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + var user = _userManager.GetUserById(User.GetUserId()); + + if (!item.CanDelete(user)) + { + return Unauthorized("Unauthorized access"); + } + + _libraryManager.DeleteItem( + item, + new DeleteOptions { DeleteFileLocation = true }, + true); + + return NoContent(); + } + + /// <summary> + /// Deletes items from the library and filesystem. + /// </summary> + /// <param name="ids">The item ids.</param> + /// <response code="204">Items deleted.</response> + /// <response code="401">Unauthorized access.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Items")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + if (ids.Length == 0) + { return NoContent(); } - /// <summary> - /// Deletes an item from the library and filesystem. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <response code="204">Item deleted.</response> - /// <response code="401">Unauthorized access.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Items/{itemId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItem(Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); + foreach (var i in ids) + { + var item = _libraryManager.GetItemById(i); var user = _userManager.GetUserById(User.GetUserId()); if (!item.CanDelete(user)) { - return Unauthorized("Unauthorized access"); + if (ids.Length > 1) + { + return Unauthorized("Unauthorized access"); + } + + continue; } _libraryManager.DeleteItem( item, new DeleteOptions { DeleteFileLocation = true }, true); - - return NoContent(); } - /// <summary> - /// Deletes items from the library and filesystem. - /// </summary> - /// <param name="ids">The item ids.</param> - /// <response code="204">Items deleted.</response> - /// <response code="401">Unauthorized access.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Items")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) - { - if (ids.Length == 0) - { - return NoContent(); - } - - foreach (var i in ids) - { - var item = _libraryManager.GetItemById(i); - var user = _userManager.GetUserById(User.GetUserId()); + return NoContent(); + } - if (!item.CanDelete(user)) - { - if (ids.Length > 1) - { - return Unauthorized("Unauthorized access"); - } + /// <summary> + /// Get item counts. + /// </summary> + /// <param name="userId">Optional. Get counts from a specific user's library.</param> + /// <param name="isFavorite">Optional. Get counts of favorite items.</param> + /// <response code="200">Item counts returned.</response> + /// <returns>Item counts.</returns> + [HttpGet("Items/Counts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<ItemCounts> GetItemCounts( + [FromQuery] Guid? userId, + [FromQuery] bool? isFavorite) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - continue; - } + var counts = new ItemCounts + { + AlbumCount = GetCount(BaseItemKind.MusicAlbum, user, isFavorite), + EpisodeCount = GetCount(BaseItemKind.Episode, user, isFavorite), + MovieCount = GetCount(BaseItemKind.Movie, user, isFavorite), + SeriesCount = GetCount(BaseItemKind.Series, user, isFavorite), + SongCount = GetCount(BaseItemKind.Audio, user, isFavorite), + MusicVideoCount = GetCount(BaseItemKind.MusicVideo, user, isFavorite), + BoxSetCount = GetCount(BaseItemKind.BoxSet, user, isFavorite), + BookCount = GetCount(BaseItemKind.Book, user, isFavorite) + }; + + return counts; + } - _libraryManager.DeleteItem( - item, - new DeleteOptions { DeleteFileLocation = true }, - true); - } + /// <summary> + /// Gets all parents of an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Item parents returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>Item parents.</returns> + [HttpGet("Items/{itemId}/Ancestors")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) + { + var item = _libraryManager.GetItemById(itemId); - return NoContent(); + if (item is null) + { + return NotFound("Item not found"); } - /// <summary> - /// Get item counts. - /// </summary> - /// <param name="userId">Optional. Get counts from a specific user's library.</param> - /// <param name="isFavorite">Optional. Get counts of favorite items.</param> - /// <response code="200">Item counts returned.</response> - /// <returns>Item counts.</returns> - [HttpGet("Items/Counts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<ItemCounts> GetItemCounts( - [FromQuery] Guid? userId, - [FromQuery] bool? isFavorite) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var counts = new ItemCounts - { - AlbumCount = GetCount(BaseItemKind.MusicAlbum, user, isFavorite), - EpisodeCount = GetCount(BaseItemKind.Episode, user, isFavorite), - MovieCount = GetCount(BaseItemKind.Movie, user, isFavorite), - SeriesCount = GetCount(BaseItemKind.Series, user, isFavorite), - SongCount = GetCount(BaseItemKind.Audio, user, isFavorite), - MusicVideoCount = GetCount(BaseItemKind.MusicVideo, user, isFavorite), - BoxSetCount = GetCount(BaseItemKind.BoxSet, user, isFavorite), - BookCount = GetCount(BaseItemKind.Book, user, isFavorite) - }; - - return counts; - } - - /// <summary> - /// Gets all parents of an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Item parents returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>Item parents.</returns> - [HttpGet("Items/{itemId}/Ancestors")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) - { - var item = _libraryManager.GetItemById(itemId); - - if (item is null) + var baseItemDtos = new List<BaseItemDto>(); + + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var dtoOptions = new DtoOptions().AddClientFields(User); + BaseItem? parent = item.GetParent(); + + while (parent is not null) + { + if (user is not null) { - return NotFound("Item not found"); + parent = TranslateParentItem(parent, user); } - var baseItemDtos = new List<BaseItemDto>(); + baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + parent = parent?.GetParent(); + } - var dtoOptions = new DtoOptions().AddClientFields(User); - BaseItem? parent = item.GetParent(); + return baseItemDtos; + } - while (parent is not null) - { - if (user is not null) - { - parent = TranslateParentItem(parent, user); - } + /// <summary> + /// Gets a list of physical paths from virtual folders. + /// </summary> + /// <response code="200">Physical paths returned.</response> + /// <returns>List of physical paths.</returns> + [HttpGet("Library/PhysicalPaths")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<string>> GetPhysicalPaths() + { + return Ok(_libraryManager.RootFolder.Children + .SelectMany(c => c.PhysicalLocations)); + } - baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); + /// <summary> + /// Gets all user media folders. + /// </summary> + /// <param name="isHidden">Optional. Filter by folders that are marked hidden, or not.</param> + /// <response code="200">Media folders returned.</response> + /// <returns>List of user media folders.</returns> + [HttpGet("Library/MediaFolders")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden) + { + var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList(); - parent = parent?.GetParent(); - } + if (isHidden.HasValue) + { + var val = isHidden.Value; - return baseItemDtos; + items = items.Where(i => i.IsHidden == val).ToList(); } - /// <summary> - /// Gets a list of physical paths from virtual folders. - /// </summary> - /// <response code="200">Physical paths returned.</response> - /// <returns>List of physical paths.</returns> - [HttpGet("Library/PhysicalPaths")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<string>> GetPhysicalPaths() + var dtoOptions = new DtoOptions().AddClientFields(User); + var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions); + return new QueryResult<BaseItemDto>(resultArray); + } + + /// <summary> + /// Reports that new episodes of a series have been added by an external source. + /// </summary> + /// <param name="tvdbId">The tvdbId.</param> + /// <response code="204">Report success.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Series/Added", Name = "PostAddedSeries")] + [HttpPost("Library/Series/Updated")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery { - return Ok(_libraryManager.RootFolder.Children - .SelectMany(c => c.PhysicalLocations)); - } + IncludeItemTypes = new[] { BaseItemKind.Series }, + DtoOptions = new DtoOptions(false) + { + EnableImages = false + } + }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray(); - /// <summary> - /// Gets all user media folders. - /// </summary> - /// <param name="isHidden">Optional. Filter by folders that are marked hidden, or not.</param> - /// <response code="200">Media folders returned.</response> - /// <returns>List of user media folders.</returns> - [HttpGet("Library/MediaFolders")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden) + foreach (var item in series) { - var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList(); + _libraryMonitor.ReportFileSystemChanged(item.Path); + } - if (isHidden.HasValue) - { - var val = isHidden.Value; + return NoContent(); + } - items = items.Where(i => i.IsHidden == val).ToList(); + /// <summary> + /// Reports that new movies have been added by an external source. + /// </summary> + /// <param name="tmdbId">The tmdbId.</param> + /// <param name="imdbId">The imdbId.</param> + /// <response code="204">Report success.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Movies/Added", Name = "PostAddedMovies")] + [HttpPost("Library/Movies/Updated")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostUpdatedMovies([FromQuery] string? tmdbId, [FromQuery] string? imdbId) + { + var movies = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Movie }, + DtoOptions = new DtoOptions(false) + { + EnableImages = false } + }); + + if (!string.IsNullOrWhiteSpace(imdbId)) + { + movies = movies.Where(i => string.Equals(imdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList(); + } + else if (!string.IsNullOrWhiteSpace(tmdbId)) + { + movies = movies.Where(i => string.Equals(tmdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList(); + } + else + { + movies = new List<BaseItem>(); + } - var dtoOptions = new DtoOptions().AddClientFields(User); - var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions); - return new QueryResult<BaseItemDto>(resultArray); + foreach (var item in movies) + { + _libraryMonitor.ReportFileSystemChanged(item.Path); } - /// <summary> - /// Reports that new episodes of a series have been added by an external source. - /// </summary> - /// <param name="tvdbId">The tvdbId.</param> - /// <response code="204">Report success.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Library/Series/Added", Name = "PostAddedSeries")] - [HttpPost("Library/Series/Updated")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId) + return NoContent(); + } + + /// <summary> + /// Reports that new movies have been added by an external source. + /// </summary> + /// <param name="dto">The update paths.</param> + /// <response code="204">Report success.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Library/Media/Updated")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto dto) + { + foreach (var item in dto.Updates) { - var series = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }).Where(i => string.Equals(tvdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tvdb), StringComparison.OrdinalIgnoreCase)).ToArray(); + _libraryMonitor.ReportFileSystemChanged(item.Path ?? throw new ArgumentException("Item path can't be null.")); + } - foreach (var item in series) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } + return NoContent(); + } - return NoContent(); + /// <summary> + /// Downloads item media. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">Media downloaded.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="FileResult"/> containing the media stream.</returns> + /// <exception cref="ArgumentException">User can't download or item can't be downloaded.</exception> + [HttpGet("Items/{itemId}/Download")] + [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); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Reports that new movies have been added by an external source. - /// </summary> - /// <param name="tmdbId">The tmdbId.</param> - /// <param name="imdbId">The imdbId.</param> - /// <response code="204">Report success.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Library/Movies/Added", Name = "PostAddedMovies")] - [HttpPost("Library/Movies/Updated")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostUpdatedMovies([FromQuery] string? tmdbId, [FromQuery] string? imdbId) - { - var movies = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Movie }, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }); + var user = _userManager.GetUserById(User.GetUserId()); - if (!string.IsNullOrWhiteSpace(imdbId)) - { - movies = movies.Where(i => string.Equals(imdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb), StringComparison.OrdinalIgnoreCase)).ToList(); - } - else if (!string.IsNullOrWhiteSpace(tmdbId)) + if (user is not null) + { + if (!item.CanDownload(user)) { - movies = movies.Where(i => string.Equals(tmdbId, i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Tmdb), StringComparison.OrdinalIgnoreCase)).ToList(); + throw new ArgumentException("Item does not support downloading"); } - else + } + else + { + if (!item.CanDownload()) { - movies = new List<BaseItem>(); + throw new ArgumentException("Item does not support downloading"); } + } - foreach (var item in movies) - { - _libraryMonitor.ReportFileSystemChanged(item.Path); - } + if (user is not null) + { + await LogDownloadAsync(item, user).ConfigureAwait(false); + } - return NoContent(); + // Quotes are valid in linux. They'll possibly cause issues here. + var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal); + + return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true); + } + + /// <summary> + /// Gets similar items. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="excludeArtistIds">Exclude artist ids.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <response code="200">Similar items returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns> + [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")] + [HttpGet("Items/{itemId}/Similar")] + [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums")] + [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows")] + [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies")] + [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( + [FromRoute, Required] Guid itemId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) + { + var item = itemId.Equals(default) + ? (userId is null || userId.Value.Equals(default) + ? _libraryManager.RootFolder + : _libraryManager.GetUserRootFolder()) + : _libraryManager.GetItemById(itemId); + + if (item is Episode || (item is IItemByName && item is not MusicArtist)) + { + return new QueryResult<BaseItemDto>(); } - /// <summary> - /// Reports that new movies have been added by an external source. - /// </summary> - /// <param name="dto">The update paths.</param> - /// <response code="204">Report success.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Library/Media/Updated")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PostUpdatedMedia([FromBody, Required] MediaUpdateInfoDto dto) + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User); + + var program = item as IHasProgramAttributes; + bool? isMovie = item is Movie || (program is not null && program.IsMovie) || item is Trailer; + bool? isSeries = item is Series || (program is not null && program.IsSeries); + + var includeItemTypes = new List<BaseItemKind>(); + if (isMovie.Value) { - foreach (var item in dto.Updates) + includeItemTypes.Add(BaseItemKind.Movie); + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { - _libraryMonitor.ReportFileSystemChanged(item.Path ?? throw new ArgumentException("Item path can't be null.")); + includeItemTypes.Add(BaseItemKind.Trailer); + includeItemTypes.Add(BaseItemKind.LiveTvProgram); } - - return NoContent(); + } + else if (isSeries.Value) + { + includeItemTypes.Add(BaseItemKind.Series); + } + else + { + // For non series and movie types these columns are typically null + // isSeries = null; + isMovie = null; + includeItemTypes.Add(item.GetBaseItemKind()); } - /// <summary> - /// Downloads item media. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <response code="200">Media downloaded.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="FileResult"/> containing the media stream.</returns> - /// <exception cref="ArgumentException">User can't download or item can't be downloaded.</exception> - [HttpGet("Items/{itemId}/Download")] - [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); - if (item is null) - { - return NotFound(); - } + var query = new InternalItemsQuery(user) + { + Genres = item.Genres, + Limit = limit, + IncludeItemTypes = includeItemTypes.ToArray(), + SimilarTo = item, + DtoOptions = dtoOptions, + EnableTotalRecordCount = !isMovie ?? true, + EnableGroupByMetadataKey = isMovie ?? false, + MinSimilarityScore = 2 // A remnant from album/artist scoring + }; + + // ExcludeArtistIds + if (excludeArtistIds.Length != 0) + { + query.ExcludeArtistIds = excludeArtistIds; + } - var user = _userManager.GetUserById(User.GetUserId()); + List<BaseItem> itemsResult = _libraryManager.GetItemList(query); - if (user is not null) - { - if (!item.CanDownload(user)) - { - throw new ArgumentException("Item does not support downloading"); - } - } - else - { - if (!item.CanDownload()) - { - throw new ArgumentException("Item does not support downloading"); - } - } + var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); - if (user is not null) - { - await LogDownloadAsync(item, user).ConfigureAwait(false); - } + return new QueryResult<BaseItemDto>( + query.StartIndex, + itemsResult.Count, + returnList); + } - // Quotes are valid in linux. They'll possibly cause issues here. - var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal); - - return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true); - } - - /// <summary> - /// Gets similar items. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="excludeArtistIds">Exclude artist ids.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> - /// <response code="200">Similar items returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> containing the similar items.</returns> - [HttpGet("Artists/{itemId}/Similar", Name = "GetSimilarArtists")] - [HttpGet("Items/{itemId}/Similar")] - [HttpGet("Albums/{itemId}/Similar", Name = "GetSimilarAlbums")] - [HttpGet("Shows/{itemId}/Similar", Name = "GetSimilarShows")] - [HttpGet("Movies/{itemId}/Similar", Name = "GetSimilarMovies")] - [HttpGet("Trailers/{itemId}/Similar", Name = "GetSimilarTrailers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( - [FromRoute, Required] Guid itemId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) - { - var item = itemId.Equals(default) - ? (userId is null || userId.Value.Equals(default) - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); - - if (item is Episode || (item is IItemByName && item is not MusicArtist)) - { - return new QueryResult<BaseItemDto>(); - } + /// <summary> + /// Gets the library options info. + /// </summary> + /// <param name="libraryContentType">Library content type.</param> + /// <param name="isNewLibrary">Whether this is a new library.</param> + /// <response code="200">Library options info returned.</response> + /// <returns>Library options info.</returns> + [HttpGet("Libraries/AvailableOptions")] + [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo( + [FromQuery] string? libraryContentType, + [FromQuery] bool isNewLibrary = false) + { + var result = new LibraryOptionsResultDto(); - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User); + var types = GetRepresentativeItemTypes(libraryContentType); + var typesList = types.ToList(); - var program = item as IHasProgramAttributes; - bool? isMovie = item is Movie || (program is not null && program.IsMovie) || item is Trailer; - bool? isSeries = item is Series || (program is not null && program.IsSeries); + var plugins = _providerManager.GetAllMetadataPlugins() + .Where(i => types.Contains(i.ItemType, StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => typesList.IndexOf(i.ItemType)) + .ToList(); - var includeItemTypes = new List<BaseItemKind>(); - if (isMovie.Value) + result.MetadataSavers = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver)) + .Select(i => new LibraryOptionInfoDto { - includeItemTypes.Add(BaseItemKind.Movie); - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - includeItemTypes.Add(BaseItemKind.Trailer); - includeItemTypes.Add(BaseItemKind.LiveTvProgram); - } - } - else if (isSeries.Value) - { - includeItemTypes.Add(BaseItemKind.Series); - } - else - { - // For non series and movie types these columns are typically null - // isSeries = null; - isMovie = null; - includeItemTypes.Add(item.GetBaseItemKind()); - } + Name = i.Name, + DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary) + }) + .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); - var query = new InternalItemsQuery(user) - { - Genres = item.Genres, - Limit = limit, - IncludeItemTypes = includeItemTypes.ToArray(), - SimilarTo = item, - DtoOptions = dtoOptions, - EnableTotalRecordCount = !isMovie ?? true, - EnableGroupByMetadataKey = isMovie ?? false, - MinSimilarityScore = 2 // A remnant from album/artist scoring - }; - - // ExcludeArtistIds - if (excludeArtistIds.Length != 0) + result.MetadataReaders = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider)) + .Select(i => new LibraryOptionInfoDto { - query.ExcludeArtistIds = excludeArtistIds; - } - - List<BaseItem> itemsResult = _libraryManager.GetItemList(query); + Name = i.Name, + DefaultEnabled = true + }) + .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); - var returnList = _dtoService.GetBaseItemDtos(itemsResult, dtoOptions, user); + result.SubtitleFetchers = plugins + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher)) + .Select(i => new LibraryOptionInfoDto + { + Name = i.Name, + DefaultEnabled = true + }) + .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); - return new QueryResult<BaseItemDto>( - query.StartIndex, - itemsResult.Count, - returnList); - } + var typeOptions = new List<LibraryTypeOptionsDto>(); - /// <summary> - /// Gets the library options info. - /// </summary> - /// <param name="libraryContentType">Library content type.</param> - /// <param name="isNewLibrary">Whether this is a new library.</param> - /// <response code="200">Library options info returned.</response> - /// <returns>Library options info.</returns> - [HttpGet("Libraries/AvailableOptions")] - [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo( - [FromQuery] string? libraryContentType, - [FromQuery] bool isNewLibrary = false) + foreach (var type in types) { - var result = new LibraryOptionsResultDto(); - - var types = GetRepresentativeItemTypes(libraryContentType); - var typesList = types.ToList(); + TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions); - var plugins = _providerManager.GetAllMetadataPlugins() - .Where(i => types.Contains(i.ItemType, StringComparison.OrdinalIgnoreCase)) - .OrderBy(i => typesList.IndexOf(i.ItemType)) - .ToList(); + typeOptions.Add(new LibraryTypeOptionsDto + { + Type = type, - result.MetadataSavers = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataSaver)) + MetadataFetchers = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher)) .Select(i => new LibraryOptionInfoDto { Name = i.Name, - DefaultEnabled = IsSaverEnabledByDefault(i.Name, types, isNewLibrary) + DefaultEnabled = IsMetadataFetcherEnabledByDefault(i.Name, type, isNewLibrary) }) .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .ToArray(); + .ToArray(), - result.MetadataReaders = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.LocalMetadataProvider)) + ImageFetchers = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher)) .Select(i => new LibraryOptionInfoDto { Name = i.Name, - DefaultEnabled = true + DefaultEnabled = IsImageFetcherEnabledByDefault(i.Name, type, isNewLibrary) }) .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .ToArray(); + .ToArray(), - result.SubtitleFetchers = plugins - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.SubtitleFetcher)) - .Select(i => new LibraryOptionInfoDto - { - Name = i.Name, - DefaultEnabled = true - }) - .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .ToArray(); + SupportedImageTypes = plugins + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>()) + .Distinct() + .ToArray(), - var typeOptions = new List<LibraryTypeOptionsDto>(); + DefaultImageOptions = defaultImageOptions ?? Array.Empty<ImageOption>() + }); + } - foreach (var type in types) - { - TypeOptions.DefaultImageOptions.TryGetValue(type, out var defaultImageOptions); + result.TypeOptions = typeOptions.ToArray(); - typeOptions.Add(new LibraryTypeOptionsDto - { - Type = type, - - MetadataFetchers = plugins - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.MetadataFetcher)) - .Select(i => new LibraryOptionInfoDto - { - Name = i.Name, - DefaultEnabled = IsMetadataFetcherEnabledByDefault(i.Name, type, isNewLibrary) - }) - .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .ToArray(), - - ImageFetchers = plugins - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => i.Plugins.Where(p => p.Type == MetadataPluginType.ImageFetcher)) - .Select(i => new LibraryOptionInfoDto - { - Name = i.Name, - DefaultEnabled = IsImageFetcherEnabledByDefault(i.Name, type, isNewLibrary) - }) - .DistinctBy(i => i.Name, StringComparer.OrdinalIgnoreCase) - .ToArray(), - - SupportedImageTypes = plugins - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .SelectMany(i => i.SupportedImageTypes ?? Array.Empty<ImageType>()) - .Distinct() - .ToArray(), - - DefaultImageOptions = defaultImageOptions ?? Array.Empty<ImageOption>() - }); + return result; + } + + private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite) + { + var query = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { itemKind }, + Limit = 0, + Recursive = true, + IsVirtualItem = false, + IsFavorite = isFavorite, + DtoOptions = new DtoOptions(false) + { + EnableImages = false } + }; - result.TypeOptions = typeOptions.ToArray(); + return _libraryManager.GetItemsResult(query).TotalRecordCount; + } - return result; - } + private BaseItem? TranslateParentItem(BaseItem item, User user) + { + return item.GetParent() is AggregateFolder + ? _libraryManager.GetUserRootFolder().GetChildren(user, true) + .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path)) + : item; + } - private int GetCount(BaseItemKind itemKind, User? user, bool? isFavorite) + private async Task LogDownloadAsync(BaseItem item, User user) + { + try { - var query = new InternalItemsQuery(user) + await _activityManager.CreateAsync(new ActivityLog( + string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), + "UserDownloadingContent", + User.GetUserId()) { - IncludeItemTypes = new[] { itemKind }, - Limit = 0, - Recursive = true, - IsVirtualItem = false, - IsFavorite = isFavorite, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }; - - return _libraryManager.GetItemsResult(query).TotalRecordCount; + ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), + }).ConfigureAwait(false); } - - private BaseItem? TranslateParentItem(BaseItem item, User user) + catch { - return item.GetParent() is AggregateFolder - ? _libraryManager.GetUserRootFolder().GetChildren(user, true) - .FirstOrDefault(i => i.PhysicalLocations.Contains(item.Path)) - : item; + // Logged at lower levels } + } - private async Task LogDownloadAsync(BaseItem item, User user) + private static string[] GetRepresentativeItemTypes(string? contentType) + { + return contentType switch { - try - { - await _activityManager.CreateAsync(new ActivityLog( - string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name), - "UserDownloadingContent", - User.GetUserId()) - { - ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), - }).ConfigureAwait(false); - } - catch - { - // Logged at lower levels - } - } + CollectionType.BoxSets => new[] { "BoxSet" }, + CollectionType.Playlists => new[] { "Playlist" }, + CollectionType.Movies => new[] { "Movie" }, + CollectionType.TvShows => new[] { "Series", "Season", "Episode" }, + CollectionType.Books => new[] { "Book" }, + CollectionType.Music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" }, + CollectionType.HomeVideos => new[] { "Video", "Photo" }, + CollectionType.Photos => new[] { "Video", "Photo" }, + CollectionType.MusicVideos => new[] { "MusicVideo" }, + _ => new[] { "Series", "Season", "Episode", "Movie" } + }; + } - private static string[] GetRepresentativeItemTypes(string? contentType) + private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary) + { + if (isNewLibrary) { - return contentType switch - { - CollectionType.BoxSets => new[] { "BoxSet" }, - CollectionType.Playlists => new[] { "Playlist" }, - CollectionType.Movies => new[] { "Movie" }, - CollectionType.TvShows => new[] { "Series", "Season", "Episode" }, - CollectionType.Books => new[] { "Book" }, - CollectionType.Music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" }, - CollectionType.HomeVideos => new[] { "Video", "Photo" }, - CollectionType.Photos => new[] { "Video", "Photo" }, - CollectionType.MusicVideos => new[] { "MusicVideo" }, - _ => new[] { "Series", "Season", "Episode", "Movie" } - }; - } - - private bool IsSaverEnabledByDefault(string name, string[] itemTypes, bool isNewLibrary) - { - if (isNewLibrary) - { - return false; - } + return false; + } - var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions - .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) - .ToArray(); + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => itemTypes.Contains(i.ItemType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + .ToArray(); - return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparison.OrdinalIgnoreCase)); - } + return metadataOptions.Length == 0 || metadataOptions.Any(i => !i.DisabledMetadataSavers.Contains(name, StringComparison.OrdinalIgnoreCase)); + } - private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + private bool IsMetadataFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + { + if (isNewLibrary) { - if (isNewLibrary) + if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) - { - return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) - || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) - || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase)); - } - - return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase); + return !(string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) + || string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) + || string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase)); } - var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - return metadataOptions.Length == 0 - || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); + return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "MusicBrainz", StringComparison.OrdinalIgnoreCase); } - private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + return metadataOptions.Length == 0 + || metadataOptions.Any(i => !i.DisabledMetadataFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); + } + + private bool IsImageFetcherEnabledByDefault(string name, string type, bool isNewLibrary) + { + if (isNewLibrary) { - if (isNewLibrary) + if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(name, "TheMovieDb", StringComparison.OrdinalIgnoreCase)) - { - return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) - && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase); - } - - return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) - || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase); + return !string.Equals(type, "Series", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "Season", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "Episode", StringComparison.OrdinalIgnoreCase) + && !string.Equals(type, "MusicVideo", StringComparison.OrdinalIgnoreCase); } - var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions - .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) - .ToArray(); + return string.Equals(name, "TheTVDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Screen Grabber", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "TheAudioDB", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Image Extractor", StringComparison.OrdinalIgnoreCase); + } - if (metadataOptions.Length == 0) - { - return true; - } + var metadataOptions = _serverConfigurationManager.Configuration.MetadataOptions + .Where(i => string.Equals(i.ItemType, type, StringComparison.OrdinalIgnoreCase)) + .ToArray(); - return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); + if (metadataOptions.Length == 0) + { + return true; } + + return metadataOptions.Any(i => !i.DisabledImageFetchers.Contains(name, StringComparison.OrdinalIgnoreCase)); } } diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 1c2394055..b012ff42e 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -20,308 +20,307 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The library structure controller. +/// </summary> +[Route("Library/VirtualFolders")] +[Authorize(Policy = Policies.FirstTimeSetupOrElevated)] +public class LibraryStructureController : BaseJellyfinApiController { + private readonly IServerApplicationPaths _appPaths; + private readonly ILibraryManager _libraryManager; + private readonly ILibraryMonitor _libraryMonitor; + /// <summary> - /// The library structure controller. + /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. /// </summary> - [Route("Library/VirtualFolders")] - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - public class LibraryStructureController : BaseJellyfinApiController + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> + public LibraryStructureController( + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor) { - private readonly IServerApplicationPaths _appPaths; - private readonly ILibraryManager _libraryManager; - private readonly ILibraryMonitor _libraryMonitor; - - /// <summary> - /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. - /// </summary> - /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> - /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> - public LibraryStructureController( - IServerConfigurationManager serverConfigurationManager, - ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor) - { - _appPaths = serverConfigurationManager.ApplicationPaths; - _libraryManager = libraryManager; - _libraryMonitor = libraryMonitor; - } + _appPaths = serverConfigurationManager.ApplicationPaths; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + } + + /// <summary> + /// Gets all virtual folders. + /// </summary> + /// <response code="200">Virtual folders retrieved.</response> + /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders() + { + return _libraryManager.GetVirtualFolders(true); + } + + /// <summary> + /// Adds a virtual folder. + /// </summary> + /// <param name="name">The name of the virtual folder.</param> + /// <param name="collectionType">The type of the collection.</param> + /// <param name="paths">The paths of the virtual folder.</param> + /// <param name="libraryOptionsDto">The library options.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder added.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> AddVirtualFolder( + [FromQuery] string? name, + [FromQuery] CollectionTypeOptions? collectionType, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths, + [FromBody] AddVirtualFolderDto? libraryOptionsDto, + [FromQuery] bool refreshLibrary = false) + { + var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions(); - /// <summary> - /// Gets all virtual folders. - /// </summary> - /// <response code="200">Virtual folders retrieved.</response> - /// <returns>An <see cref="IEnumerable{VirtualFolderInfo}"/> with the virtual folders.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<VirtualFolderInfo>> GetVirtualFolders() + if (paths is not null && paths.Length > 0) { - return _libraryManager.GetVirtualFolders(true); + libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray(); } - /// <summary> - /// Adds a virtual folder. - /// </summary> - /// <param name="name">The name of the virtual folder.</param> - /// <param name="collectionType">The type of the collection.</param> - /// <param name="paths">The paths of the virtual folder.</param> - /// <param name="libraryOptionsDto">The library options.</param> - /// <param name="refreshLibrary">Whether to refresh the library.</param> - /// <response code="204">Folder added.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> AddVirtualFolder( - [FromQuery] string? name, - [FromQuery] CollectionTypeOptions? collectionType, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths, - [FromBody] AddVirtualFolderDto? libraryOptionsDto, - [FromQuery] bool refreshLibrary = false) - { - var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions(); + await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false); - if (paths is not null && paths.Length > 0) - { - libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray(); - } + return NoContent(); + } - await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false); + /// <summary> + /// Removes a virtual folder. + /// </summary> + /// <param name="name">The name of the folder.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder removed.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RemoveVirtualFolder( + [FromQuery] string? name, + [FromQuery] bool refreshLibrary = false) + { + await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); + return NoContent(); + } - return NoContent(); + /// <summary> + /// Renames a virtual folder. + /// </summary> + /// <param name="name">The name of the virtual folder.</param> + /// <param name="newName">The new name.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <response code="204">Folder renamed.</response> + /// <response code="404">Library doesn't exist.</response> + /// <response code="409">Library already exists.</response> + /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns> + /// <exception cref="ArgumentNullException">The new name may not be null.</exception> + [HttpPost("Name")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public ActionResult RenameVirtualFolder( + [FromQuery] string? name, + [FromQuery] string? newName, + [FromQuery] bool refreshLibrary = false) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentNullException(nameof(name)); } - /// <summary> - /// Removes a virtual folder. - /// </summary> - /// <param name="name">The name of the folder.</param> - /// <param name="refreshLibrary">Whether to refresh the library.</param> - /// <response code="204">Folder removed.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveVirtualFolder( - [FromQuery] string? name, - [FromQuery] bool refreshLibrary = false) + if (string.IsNullOrWhiteSpace(newName)) { - await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); - return NoContent(); + throw new ArgumentNullException(nameof(newName)); } - /// <summary> - /// Renames a virtual folder. - /// </summary> - /// <param name="name">The name of the virtual folder.</param> - /// <param name="newName">The new name.</param> - /// <param name="refreshLibrary">Whether to refresh the library.</param> - /// <response code="204">Folder renamed.</response> - /// <response code="404">Library doesn't exist.</response> - /// <response code="409">Library already exists.</response> - /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns> - /// <exception cref="ArgumentNullException">The new name may not be null.</exception> - [HttpPost("Name")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - public ActionResult RenameVirtualFolder( - [FromQuery] string? name, - [FromQuery] string? newName, - [FromQuery] bool refreshLibrary = false) - { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } + var rootFolderPath = _appPaths.DefaultUserViewsPath; - if (string.IsNullOrWhiteSpace(newName)) - { - throw new ArgumentNullException(nameof(newName)); - } + var currentPath = Path.Combine(rootFolderPath, name); + var newPath = Path.Combine(rootFolderPath, newName); - var rootFolderPath = _appPaths.DefaultUserViewsPath; + if (!Directory.Exists(currentPath)) + { + return NotFound("The media collection does not exist."); + } - var currentPath = Path.Combine(rootFolderPath, name); - var newPath = Path.Combine(rootFolderPath, newName); + if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath)) + { + return Conflict($"The media library already exists at {newPath}."); + } - if (!Directory.Exists(currentPath)) - { - return NotFound("The media collection does not exist."); - } + _libraryMonitor.Stop(); - if (!string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase) && Directory.Exists(newPath)) + try + { + // Changing capitalization. Handle windows case insensitivity + if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase)) { - return Conflict($"The media library already exists at {newPath}."); + var tempPath = Path.Combine( + rootFolderPath, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); + Directory.Move(currentPath, tempPath); + currentPath = tempPath; } - _libraryMonitor.Stop(); + Directory.Move(currentPath, newPath); + } + finally + { + CollectionFolder.OnCollectionFolderChange(); - try + Task.Run(async () => { - // Changing capitalization. Handle windows case insensitivity - if (string.Equals(currentPath, newPath, StringComparison.OrdinalIgnoreCase)) + // No need to start if scanning the library because it will handle it + if (refreshLibrary) { - var tempPath = Path.Combine( - rootFolderPath, - Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)); - Directory.Move(currentPath, tempPath); - currentPath = tempPath; + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); } - - Directory.Move(currentPath, newPath); - } - finally - { - CollectionFolder.OnCollectionFolderChange(); - - Task.Run(async () => + else { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - // Have to block here to allow exceptions to bubble - await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitor.Start(); - } - }); - } - - return NoContent(); + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); } - /// <summary> - /// Add a media path to a library. - /// </summary> - /// <param name="mediaPathDto">The media path dto.</param> - /// <param name="refreshLibrary">Whether to refresh the library.</param> - /// <returns>A <see cref="NoContentResult"/>.</returns> - /// <response code="204">Media path added.</response> - /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> - [HttpPost("Paths")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult AddMediaPath( - [FromBody, Required] MediaPathDto mediaPathDto, - [FromQuery] bool refreshLibrary = false) - { - _libraryMonitor.Stop(); + return NoContent(); + } - try - { - var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException("PathInfo and Path can't both be null.")); + /// <summary> + /// Add a media path to a library. + /// </summary> + /// <param name="mediaPathDto">The media path dto.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path added.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpPost("Paths")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddMediaPath( + [FromBody, Required] MediaPathDto mediaPathDto, + [FromQuery] bool refreshLibrary = false) + { + _libraryMonitor.Stop(); - _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath); - } - finally - { - Task.Run(async () => - { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - // Have to block here to allow exceptions to bubble - await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitor.Start(); - } - }); - } + try + { + var mediaPath = mediaPathDto.PathInfo ?? new MediaPathInfo(mediaPathDto.Path ?? throw new ArgumentException("PathInfo and Path can't both be null.")); - return NoContent(); + _libraryManager.AddMediaPath(mediaPathDto.Name, mediaPath); } - - /// <summary> - /// Updates a media path. - /// </summary> - /// <param name="mediaPathRequestDto">The name of the library and path infos.</param> - /// <returns>A <see cref="NoContentResult"/>.</returns> - /// <response code="204">Media path updated.</response> - /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> - [HttpPost("Paths/Update")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateMediaPath([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto) + finally { - if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name)) + Task.Run(async () => { - throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty"); - } + // No need to start if scanning the library because it will handle it + if (refreshLibrary) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); + } + + return NoContent(); + } - _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo); - return NoContent(); + /// <summary> + /// Updates a media path. + /// </summary> + /// <param name="mediaPathRequestDto">The name of the library and path infos.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path updated.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpPost("Paths/Update")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateMediaPath([FromBody, Required] UpdateMediaPathRequestDto mediaPathRequestDto) + { + if (string.IsNullOrWhiteSpace(mediaPathRequestDto.Name)) + { + throw new ArgumentNullException(nameof(mediaPathRequestDto), "Name must not be null or empty"); } - /// <summary> - /// Remove a media path. - /// </summary> - /// <param name="name">The name of the library.</param> - /// <param name="path">The path to remove.</param> - /// <param name="refreshLibrary">Whether to refresh the library.</param> - /// <returns>A <see cref="NoContentResult"/>.</returns> - /// <response code="204">Media path removed.</response> - /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> - [HttpDelete("Paths")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RemoveMediaPath( - [FromQuery] string? name, - [FromQuery] string? path, - [FromQuery] bool refreshLibrary = false) + _libraryManager.UpdateMediaPath(mediaPathRequestDto.Name, mediaPathRequestDto.PathInfo); + return NoContent(); + } + + /// <summary> + /// Remove a media path. + /// </summary> + /// <param name="name">The name of the library.</param> + /// <param name="path">The path to remove.</param> + /// <param name="refreshLibrary">Whether to refresh the library.</param> + /// <returns>A <see cref="NoContentResult"/>.</returns> + /// <response code="204">Media path removed.</response> + /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + [HttpDelete("Paths")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveMediaPath( + [FromQuery] string? name, + [FromQuery] string? path, + [FromQuery] bool refreshLibrary = false) + { + if (string.IsNullOrWhiteSpace(name)) { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } + throw new ArgumentNullException(nameof(name)); + } - _libraryMonitor.Stop(); + _libraryMonitor.Stop(); - try - { - _libraryManager.RemoveMediaPath(name, path); - } - finally + try + { + _libraryManager.RemoveMediaPath(name, path); + } + finally + { + Task.Run(async () => { - Task.Run(async () => + // No need to start if scanning the library because it will handle it + if (refreshLibrary) { - // No need to start if scanning the library because it will handle it - if (refreshLibrary) - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); - } - else - { - // Need to add a delay here or directory watchers may still pick up the changes - // Have to block here to allow exceptions to bubble - await Task.Delay(1000).ConfigureAwait(false); - _libraryMonitor.Start(); - } - }); - } - - return NoContent(); + await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + } + else + { + // Need to add a delay here or directory watchers may still pick up the changes + // Have to block here to allow exceptions to bubble + await Task.Delay(1000).ConfigureAwait(false); + _libraryMonitor.Start(); + } + }); } - /// <summary> - /// Update library options. - /// </summary> - /// <param name="request">The library name and options.</param> - /// <response code="204">Library updated.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("LibraryOptions")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateLibraryOptions( - [FromBody] UpdateLibraryOptionsDto request) - { - var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id); + return NoContent(); + } - collectionFolder.UpdateLibraryOptions(request.LibraryOptions); - return NoContent(); - } + /// <summary> + /// Update library options. + /// </summary> + /// <param name="request">The library name and options.</param> + /// <response code="204">Library updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("LibraryOptions")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateLibraryOptions( + [FromBody] UpdateLibraryOptionsDto request) + { + var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id); + + collectionFolder.UpdateLibraryOptions(request.LibraryOptions); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 5228e0bab..21b424346 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -35,1200 +35,1199 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Live tv controller. +/// </summary> +public class LiveTvController : BaseJellyfinApiController { + private readonly ILiveTvManager _liveTvManager; + private readonly IUserManager _userManager; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IConfigurationManager _configurationManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly ISessionManager _sessionManager; + /// <summary> - /// Live tv controller. + /// Initializes a new instance of the <see cref="LiveTvController"/> class. /// </summary> - public class LiveTvController : BaseJellyfinApiController + /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + public LiveTvController( + ILiveTvManager liveTvManager, + IUserManager userManager, + IHttpClientFactory httpClientFactory, + ILibraryManager libraryManager, + IDtoService dtoService, + IMediaSourceManager mediaSourceManager, + IConfigurationManager configurationManager, + TranscodingJobHelper transcodingJobHelper, + ISessionManager sessionManager) { - private readonly ILiveTvManager _liveTvManager; - private readonly IUserManager _userManager; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IConfigurationManager _configurationManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly ISessionManager _sessionManager; - - /// <summary> - /// Initializes a new instance of the <see cref="LiveTvController"/> class. - /// </summary> - /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> - /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - public LiveTvController( - ILiveTvManager liveTvManager, - IUserManager userManager, - IHttpClientFactory httpClientFactory, - ILibraryManager libraryManager, - IDtoService dtoService, - IMediaSourceManager mediaSourceManager, - IConfigurationManager configurationManager, - TranscodingJobHelper transcodingJobHelper, - ISessionManager sessionManager) - { - _liveTvManager = liveTvManager; - _userManager = userManager; - _httpClientFactory = httpClientFactory; - _libraryManager = libraryManager; - _dtoService = dtoService; - _mediaSourceManager = mediaSourceManager; - _configurationManager = configurationManager; - _transcodingJobHelper = transcodingJobHelper; - _sessionManager = sessionManager; - } + _liveTvManager = liveTvManager; + _userManager = userManager; + _httpClientFactory = httpClientFactory; + _libraryManager = libraryManager; + _dtoService = dtoService; + _mediaSourceManager = mediaSourceManager; + _configurationManager = configurationManager; + _transcodingJobHelper = transcodingJobHelper; + _sessionManager = sessionManager; + } - /// <summary> - /// Gets available live tv services. - /// </summary> - /// <response code="200">Available live tv services returned.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the available live tv services. - /// </returns> - [HttpGet("Info")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<LiveTvInfo> GetLiveTvInfo() - { - return _liveTvManager.GetLiveTvInfo(CancellationToken.None); - } + /// <summary> + /// Gets available live tv services. + /// </summary> + /// <response code="200">Available live tv services returned.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the available live tv services. + /// </returns> + [HttpGet("Info")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<LiveTvInfo> GetLiveTvInfo() + { + return _liveTvManager.GetLiveTvInfo(CancellationToken.None); + } - /// <summary> - /// Gets available live tv channels. - /// </summary> - /// <param name="type">Optional. Filter by channel type.</param> - /// <param name="userId">Optional. Filter by user and attach user data.</param> - /// <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="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="limit">Optional. The maximum number of records to return.</param> - /// <param name="isFavorite">Optional. Filter by channels that are favorites, or not.</param> - /// <param name="isLiked">Optional. Filter by channels that are liked, or not.</param> - /// <param name="isDisliked">Optional. Filter by channels that are disliked, or not.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">"Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="sortBy">Optional. Key to sort by.</param> - /// <param name="sortOrder">Optional. Sort order.</param> - /// <param name="enableFavoriteSorting">Optional. Incorporate favorite and like status into channel sorting.</param> - /// <param name="addCurrentProgram">Optional. Adds current program info to each channel.</param> - /// <response code="200">Available live tv channels returned.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the resulting available live tv channels. - /// </returns> - [HttpGet("Channels")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<QueryResult<BaseItemDto>> GetLiveTvChannels( - [FromQuery] ChannelType? type, - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery] int? limit, - [FromQuery] bool? isFavorite, - [FromQuery] bool? isLiked, - [FromQuery] bool? isDisliked, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableUserData, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] SortOrder? sortOrder, - [FromQuery] bool enableFavoriteSorting = false, - [FromQuery] bool addCurrentProgram = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - var channelResult = _liveTvManager.GetInternalChannels( - new LiveTvChannelQuery - { - ChannelType = type, - UserId = userId ?? Guid.Empty, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - IsLiked = isLiked, - IsDisliked = isDisliked, - EnableFavoriteSorting = enableFavoriteSorting, - IsMovie = isMovie, - IsSeries = isSeries, - IsNews = isNews, - IsKids = isKids, - IsSports = isSports, - SortBy = sortBy, - SortOrder = sortOrder ?? SortOrder.Ascending, - AddCurrentProgram = addCurrentProgram - }, - dtoOptions, - CancellationToken.None); - - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var fieldsList = dtoOptions.Fields.ToList(); - fieldsList.Remove(ItemFields.CanDelete); - fieldsList.Remove(ItemFields.CanDownload); - fieldsList.Remove(ItemFields.DisplayPreferencesId); - fieldsList.Remove(ItemFields.Etag); - dtoOptions.Fields = fieldsList.ToArray(); - dtoOptions.AddCurrentProgram = addCurrentProgram; - - var returnArray = _dtoService.GetBaseItemDtos(channelResult.Items, dtoOptions, user); - return new QueryResult<BaseItemDto>( - startIndex, - channelResult.TotalRecordCount, - returnArray); - } + /// <summary> + /// Gets available live tv channels. + /// </summary> + /// <param name="type">Optional. Filter by channel type.</param> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <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="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="limit">Optional. The maximum number of records to return.</param> + /// <param name="isFavorite">Optional. Filter by channels that are favorites, or not.</param> + /// <param name="isLiked">Optional. Filter by channels that are liked, or not.</param> + /// <param name="isDisliked">Optional. Filter by channels that are disliked, or not.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">"Optional. The image types to include in the output.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="sortBy">Optional. Key to sort by.</param> + /// <param name="sortOrder">Optional. Sort order.</param> + /// <param name="enableFavoriteSorting">Optional. Incorporate favorite and like status into channel sorting.</param> + /// <param name="addCurrentProgram">Optional. Adds current program info to each channel.</param> + /// <response code="200">Available live tv channels returned.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the resulting available live tv channels. + /// </returns> + [HttpGet("Channels")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<QueryResult<BaseItemDto>> GetLiveTvChannels( + [FromQuery] ChannelType? type, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] int? limit, + [FromQuery] bool? isFavorite, + [FromQuery] bool? isLiked, + [FromQuery] bool? isDisliked, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableUserData, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery] SortOrder? sortOrder, + [FromQuery] bool enableFavoriteSorting = false, + [FromQuery] bool addCurrentProgram = true) + { + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - /// <summary> - /// Gets a live tv channel. - /// </summary> - /// <param name="channelId">Channel id.</param> - /// <param name="userId">Optional. Attach user data.</param> - /// <response code="200">Live tv channel returned.</response> - /// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns> - [HttpGet("Channels/{channelId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var item = channelId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(channelId); - - var dtoOptions = new DtoOptions() - .AddClientFields(User); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + var channelResult = _liveTvManager.GetInternalChannels( + new LiveTvChannelQuery + { + ChannelType = type, + UserId = userId ?? Guid.Empty, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + IsLiked = isLiked, + IsDisliked = isDisliked, + EnableFavoriteSorting = enableFavoriteSorting, + IsMovie = isMovie, + IsSeries = isSeries, + IsNews = isNews, + IsKids = isKids, + IsSports = isSports, + SortBy = sortBy, + SortOrder = sortOrder ?? SortOrder.Ascending, + AddCurrentProgram = addCurrentProgram + }, + dtoOptions, + CancellationToken.None); + + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var fieldsList = dtoOptions.Fields.ToList(); + fieldsList.Remove(ItemFields.CanDelete); + fieldsList.Remove(ItemFields.CanDownload); + fieldsList.Remove(ItemFields.DisplayPreferencesId); + fieldsList.Remove(ItemFields.Etag); + dtoOptions.Fields = fieldsList.ToArray(); + dtoOptions.AddCurrentProgram = addCurrentProgram; + + var returnArray = _dtoService.GetBaseItemDtos(channelResult.Items, dtoOptions, user); + return new QueryResult<BaseItemDto>( + startIndex, + channelResult.TotalRecordCount, + returnArray); + } - /// <summary> - /// Gets live tv recordings. - /// </summary> - /// <param name="channelId">Optional. Filter by channel id.</param> - /// <param name="userId">Optional. Filter by user and attach user data.</param> - /// <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="status">Optional. Filter by recording status.</param> - /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param> - /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="isMovie">Optional. Filter for movies.</param> - /// <param name="isSeries">Optional. Filter for series.</param> - /// <param name="isKids">Optional. Filter for kids.</param> - /// <param name="isSports">Optional. Filter for sports.</param> - /// <param name="isNews">Optional. Filter for news.</param> - /// <param name="isLibraryItem">Optional. Filter for is library item.</param> - /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> - /// <response code="200">Live tv recordings returned.</response> - /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns> - [HttpGet("Recordings")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<QueryResult<BaseItemDto>> GetRecordings( - [FromQuery] string? channelId, - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] RecordingStatus? status, - [FromQuery] bool? isInProgress, - [FromQuery] string? seriesTimerId, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableUserData, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery] bool? isNews, - [FromQuery] bool? isLibraryItem, - [FromQuery] bool enableTotalRecordCount = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - return _liveTvManager.GetRecordings( - new RecordingQuery - { - ChannelId = channelId, - UserId = userId ?? Guid.Empty, - StartIndex = startIndex, - Limit = limit, - Status = status, - SeriesTimerId = seriesTimerId, - IsInProgress = isInProgress, - EnableTotalRecordCount = enableTotalRecordCount, - IsMovie = isMovie, - IsNews = isNews, - IsSeries = isSeries, - IsKids = isKids, - IsSports = isSports, - IsLibraryItem = isLibraryItem, - Fields = fields, - ImageTypeLimit = imageTypeLimit, - EnableImages = enableImages - }, - dtoOptions); - } + /// <summary> + /// Gets a live tv channel. + /// </summary> + /// <param name="channelId">Channel id.</param> + /// <param name="userId">Optional. Attach user data.</param> + /// <response code="200">Live tv channel returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns> + [HttpGet("Channels/{channelId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var item = channelId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(channelId); + + var dtoOptions = new DtoOptions() + .AddClientFields(User); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } - /// <summary> - /// Gets live tv recording series. - /// </summary> - /// <param name="channelId">Optional. Filter by channel id.</param> - /// <param name="userId">Optional. Filter by user and attach user data.</param> - /// <param name="groupId">Optional. Filter by recording group.</param> - /// <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="status">Optional. Filter by recording status.</param> - /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param> - /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> - /// <response code="200">Live tv recordings returned.</response> - /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns> - [HttpGet("Recordings/Series")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - [Obsolete("This endpoint is obsolete.")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")] - public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries( - [FromQuery] string? channelId, - [FromQuery] Guid? userId, - [FromQuery] string? groupId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] RecordingStatus? status, - [FromQuery] bool? isInProgress, - [FromQuery] string? seriesTimerId, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableUserData, - [FromQuery] bool enableTotalRecordCount = true) - { - return new QueryResult<BaseItemDto>(); - } + /// <summary> + /// Gets live tv recordings. + /// </summary> + /// <param name="channelId">Optional. Filter by channel id.</param> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <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="status">Optional. Filter by recording status.</param> + /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param> + /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="isMovie">Optional. Filter for movies.</param> + /// <param name="isSeries">Optional. Filter for series.</param> + /// <param name="isKids">Optional. Filter for kids.</param> + /// <param name="isSports">Optional. Filter for sports.</param> + /// <param name="isNews">Optional. Filter for news.</param> + /// <param name="isLibraryItem">Optional. Filter for is library item.</param> + /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> + /// <response code="200">Live tv recordings returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns> + [HttpGet("Recordings")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<QueryResult<BaseItemDto>> GetRecordings( + [FromQuery] string? channelId, + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] RecordingStatus? status, + [FromQuery] bool? isInProgress, + [FromQuery] string? seriesTimerId, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableUserData, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] bool? isNews, + [FromQuery] bool? isLibraryItem, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - /// <summary> - /// Gets live tv recording groups. - /// </summary> - /// <param name="userId">Optional. Filter by user and attach user data.</param> - /// <response code="200">Recording groups returned.</response> - /// <returns>An <see cref="OkResult"/> containing the recording groups.</returns> - [HttpGet("Recordings/Groups")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - [Obsolete("This endpoint is obsolete.")] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] - public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId) - { - return new QueryResult<BaseItemDto>(); - } + return _liveTvManager.GetRecordings( + new RecordingQuery + { + ChannelId = channelId, + UserId = userId ?? Guid.Empty, + StartIndex = startIndex, + Limit = limit, + Status = status, + SeriesTimerId = seriesTimerId, + IsInProgress = isInProgress, + EnableTotalRecordCount = enableTotalRecordCount, + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsKids = isKids, + IsSports = isSports, + IsLibraryItem = isLibraryItem, + Fields = fields, + ImageTypeLimit = imageTypeLimit, + EnableImages = enableImages + }, + dtoOptions); + } - /// <summary> - /// Gets recording folders. - /// </summary> - /// <param name="userId">Optional. Filter by user and attach user data.</param> - /// <response code="200">Recording folders returned.</response> - /// <returns>An <see cref="OkResult"/> containing the recording folders.</returns> - [HttpGet("Recordings/Folders")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<QueryResult<BaseItemDto>> GetRecordingFolders([FromQuery] Guid? userId) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var folders = _liveTvManager.GetRecordingFolders(user); + /// <summary> + /// Gets live tv recording series. + /// </summary> + /// <param name="channelId">Optional. Filter by channel id.</param> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <param name="groupId">Optional. Filter by recording group.</param> + /// <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="status">Optional. Filter by recording status.</param> + /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param> + /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="enableTotalRecordCount">Optional. Return total record count.</param> + /// <response code="200">Live tv recordings returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns> + [HttpGet("Recordings/Series")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + [Obsolete("This endpoint is obsolete.")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")] + public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries( + [FromQuery] string? channelId, + [FromQuery] Guid? userId, + [FromQuery] string? groupId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] RecordingStatus? status, + [FromQuery] bool? isInProgress, + [FromQuery] string? seriesTimerId, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableUserData, + [FromQuery] bool enableTotalRecordCount = true) + { + return new QueryResult<BaseItemDto>(); + } - var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user); + /// <summary> + /// Gets live tv recording groups. + /// </summary> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <response code="200">Recording groups returned.</response> + /// <returns>An <see cref="OkResult"/> containing the recording groups.</returns> + [HttpGet("Recordings/Groups")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + [Obsolete("This endpoint is obsolete.")] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")] + public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId) + { + return new QueryResult<BaseItemDto>(); + } - return new QueryResult<BaseItemDto>(returnArray); - } + /// <summary> + /// Gets recording folders. + /// </summary> + /// <param name="userId">Optional. Filter by user and attach user data.</param> + /// <response code="200">Recording folders returned.</response> + /// <returns>An <see cref="OkResult"/> containing the recording folders.</returns> + [HttpGet("Recordings/Folders")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<QueryResult<BaseItemDto>> GetRecordingFolders([FromQuery] Guid? userId) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var folders = _liveTvManager.GetRecordingFolders(user); - /// <summary> - /// Gets a live tv recording. - /// </summary> - /// <param name="recordingId">Recording id.</param> - /// <param name="userId">Optional. Attach user data.</param> - /// <response code="200">Recording returned.</response> - /// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns> - [HttpGet("Recordings/{recordingId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var item = recordingId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId); + var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user); - var dtoOptions = new DtoOptions() - .AddClientFields(User); + return new QueryResult<BaseItemDto>(returnArray); + } - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + /// <summary> + /// Gets a live tv recording. + /// </summary> + /// <param name="recordingId">Recording id.</param> + /// <param name="userId">Optional. Attach user data.</param> + /// <response code="200">Recording returned.</response> + /// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns> + [HttpGet("Recordings/{recordingId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var item = recordingId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId); - /// <summary> - /// Resets a tv tuner. - /// </summary> - /// <param name="tunerId">Tuner id.</param> - /// <response code="204">Tuner reset.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Tuners/{tunerId}/Reset")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + var dtoOptions = new DtoOptions() + .AddClientFields(User); - /// <summary> - /// Gets a timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <response code="200">Timer returned.</response> - /// <returns> - /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the timer. - /// </returns> - [HttpGet("Timers/{timerId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult<TimerInfoDto>> GetTimer([FromRoute, Required] string timerId) - { - return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false); - } + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } - /// <summary> - /// Gets the default values for a new timer. - /// </summary> - /// <param name="programId">Optional. To attach default values based on a program.</param> - /// <response code="200">Default values returned.</response> - /// <returns> - /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the default values for a timer. - /// </returns> - [HttpGet("Timers/Defaults")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult<SeriesTimerInfoDto>> GetDefaultTimer([FromQuery] string? programId) - { - return string.IsNullOrEmpty(programId) - ? await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false) - : await _liveTvManager.GetNewTimerDefaults(programId, CancellationToken.None).ConfigureAwait(false); - } + /// <summary> + /// Resets a tv tuner. + /// </summary> + /// <param name="tunerId">Tuner id.</param> + /// <response code="204">Tuner reset.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Tuners/{tunerId}/Reset")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId) + { + await AssertUserCanManageLiveTv().ConfigureAwait(false); + await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Gets the live tv timers. - /// </summary> - /// <param name="channelId">Optional. Filter by channel id.</param> - /// <param name="seriesTimerId">Optional. Filter by timers belonging to a series timer.</param> - /// <param name="isActive">Optional. Filter by timers that are active.</param> - /// <param name="isScheduled">Optional. Filter by timers that are scheduled.</param> - /// <returns> - /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the live tv timers. - /// </returns> - [HttpGet("Timers")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult<QueryResult<TimerInfoDto>>> GetTimers( - [FromQuery] string? channelId, - [FromQuery] string? seriesTimerId, - [FromQuery] bool? isActive, - [FromQuery] bool? isScheduled) - { - return await _liveTvManager.GetTimers( - new TimerQuery - { - ChannelId = channelId, - SeriesTimerId = seriesTimerId, - IsActive = isActive, - IsScheduled = isScheduled - }, - CancellationToken.None).ConfigureAwait(false); - } + /// <summary> + /// Gets a timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="200">Timer returned.</response> + /// <returns> + /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the timer. + /// </returns> + [HttpGet("Timers/{timerId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult<TimerInfoDto>> GetTimer([FromRoute, Required] string timerId) + { + return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false); + } - /// <summary> - /// Gets available live tv epgs. - /// </summary> - /// <param name="channelIds">The channels to return guide information for.</param> - /// <param name="userId">Optional. Filter by user id.</param> - /// <param name="minStartDate">Optional. The minimum premiere start date.</param> - /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param> - /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param> - /// <param name="maxStartDate">Optional. The maximum premiere start date.</param> - /// <param name="minEndDate">Optional. The minimum premiere end date.</param> - /// <param name="maxEndDate">Optional. The maximum premiere end date.</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="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="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="genres">The genres to return guide information for.</param> - /// <param name="genreIds">The genre ids to return guide information for.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="seriesTimerId">Optional. Filter by series timer id.</param> - /// <param name="librarySeriesId">Optional. Filter by library series id.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableTotalRecordCount">Retrieve total record count.</param> - /// <response code="200">Live tv epgs returned.</response> - /// <returns> - /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs. - /// </returns> - [HttpGet("Programs")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms( - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds, - [FromQuery] Guid? userId, - [FromQuery] DateTime? minStartDate, - [FromQuery] bool? hasAired, - [FromQuery] bool? isAiring, - [FromQuery] DateTime? maxStartDate, - [FromQuery] DateTime? minEndDate, - [FromQuery] DateTime? maxEndDate, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData, - [FromQuery] string? seriesTimerId, - [FromQuery] Guid? librarySeriesId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool enableTotalRecordCount = true) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + /// <summary> + /// Gets the default values for a new timer. + /// </summary> + /// <param name="programId">Optional. To attach default values based on a program.</param> + /// <response code="200">Default values returned.</response> + /// <returns> + /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the default values for a timer. + /// </returns> + [HttpGet("Timers/Defaults")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult<SeriesTimerInfoDto>> GetDefaultTimer([FromQuery] string? programId) + { + return string.IsNullOrEmpty(programId) + ? await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false) + : await _liveTvManager.GetNewTimerDefaults(programId, CancellationToken.None).ConfigureAwait(false); + } - var query = new InternalItemsQuery(user) + /// <summary> + /// Gets the live tv timers. + /// </summary> + /// <param name="channelId">Optional. Filter by channel id.</param> + /// <param name="seriesTimerId">Optional. Filter by timers belonging to a series timer.</param> + /// <param name="isActive">Optional. Filter by timers that are active.</param> + /// <param name="isScheduled">Optional. Filter by timers that are scheduled.</param> + /// <returns> + /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the live tv timers. + /// </returns> + [HttpGet("Timers")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult<QueryResult<TimerInfoDto>>> GetTimers( + [FromQuery] string? channelId, + [FromQuery] string? seriesTimerId, + [FromQuery] bool? isActive, + [FromQuery] bool? isScheduled) + { + return await _liveTvManager.GetTimers( + new TimerQuery { - ChannelIds = channelIds, - HasAired = hasAired, - IsAiring = isAiring, - EnableTotalRecordCount = enableTotalRecordCount, - MinStartDate = minStartDate, - MinEndDate = minEndDate, - MaxStartDate = maxStartDate, - MaxEndDate = maxEndDate, - StartIndex = startIndex, - Limit = limit, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), - IsNews = isNews, - IsMovie = isMovie, - IsSeries = isSeries, - IsKids = isKids, - IsSports = isSports, + ChannelId = channelId, SeriesTimerId = seriesTimerId, - Genres = genres, - GenreIds = genreIds - }; - - if (librarySeriesId.HasValue && !librarySeriesId.Equals(default)) - { - query.IsSeries = true; - - if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series) - { - query.Name = series.Name; - } - } + IsActive = isActive, + IsScheduled = isScheduled + }, + CancellationToken.None).ConfigureAwait(false); + } - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); - } + /// <summary> + /// Gets available live tv epgs. + /// </summary> + /// <param name="channelIds">The channels to return guide information for.</param> + /// <param name="userId">Optional. Filter by user id.</param> + /// <param name="minStartDate">Optional. The minimum premiere start date.</param> + /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param> + /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param> + /// <param name="maxStartDate">Optional. The maximum premiere start date.</param> + /// <param name="minEndDate">Optional. The minimum premiere end date.</param> + /// <param name="maxEndDate">Optional. The maximum premiere end date.</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="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="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="genres">The genres to return guide information for.</param> + /// <param name="genreIds">The genre ids to return guide information for.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="seriesTimerId">Optional. Filter by series timer id.</param> + /// <param name="librarySeriesId">Optional. Filter by library series id.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableTotalRecordCount">Retrieve total record count.</param> + /// <response code="200">Live tv epgs returned.</response> + /// <returns> + /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs. + /// </returns> + [HttpGet("Programs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms( + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds, + [FromQuery] Guid? userId, + [FromQuery] DateTime? minStartDate, + [FromQuery] bool? hasAired, + [FromQuery] bool? isAiring, + [FromQuery] DateTime? maxStartDate, + [FromQuery] DateTime? minEndDate, + [FromQuery] DateTime? maxEndDate, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] string? seriesTimerId, + [FromQuery] Guid? librarySeriesId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool enableTotalRecordCount = true) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - /// <summary> - /// Gets available live tv epgs. - /// </summary> - /// <param name="body">Request body.</param> - /// <response code="200">Live tv epgs returned.</response> - /// <returns> - /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs. - /// </returns> - [HttpPost("Programs")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.DefaultAuthorization)] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms([FromBody] GetProgramsDto body) + var query = new InternalItemsQuery(user) { - var user = body.UserId.Equals(default) ? null : _userManager.GetUserById(body.UserId); + ChannelIds = channelIds, + HasAired = hasAired, + IsAiring = isAiring, + EnableTotalRecordCount = enableTotalRecordCount, + MinStartDate = minStartDate, + MinEndDate = minEndDate, + MaxStartDate = maxStartDate, + MaxEndDate = maxEndDate, + StartIndex = startIndex, + Limit = limit, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), + IsNews = isNews, + IsMovie = isMovie, + IsSeries = isSeries, + IsKids = isKids, + IsSports = isSports, + SeriesTimerId = seriesTimerId, + Genres = genres, + GenreIds = genreIds + }; + + if (librarySeriesId.HasValue && !librarySeriesId.Equals(default)) + { + query.IsSeries = true; - var query = new InternalItemsQuery(user) - { - ChannelIds = body.ChannelIds, - HasAired = body.HasAired, - IsAiring = body.IsAiring, - EnableTotalRecordCount = body.EnableTotalRecordCount, - MinStartDate = body.MinStartDate, - MinEndDate = body.MinEndDate, - MaxStartDate = body.MaxStartDate, - MaxEndDate = body.MaxEndDate, - StartIndex = body.StartIndex, - Limit = body.Limit, - OrderBy = RequestHelpers.GetOrderBy(body.SortBy, body.SortOrder), - IsNews = body.IsNews, - IsMovie = body.IsMovie, - IsSeries = body.IsSeries, - IsKids = body.IsKids, - IsSports = body.IsSports, - SeriesTimerId = body.SeriesTimerId, - Genres = body.Genres, - GenreIds = body.GenreIds - }; - - if (!body.LibrarySeriesId.Equals(default)) + if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series) { - query.IsSeries = true; - - if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series) - { - query.Name = series.Name; - } + query.Name = series.Name; } - - var dtoOptions = new DtoOptions { Fields = body.Fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes); - return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); } - /// <summary> - /// Gets recommended live tv epgs. - /// </summary> - /// <param name="userId">Optional. filter by user id.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param> - /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param> - /// <param name="isSeries">Optional. Filter for series.</param> - /// <param name="isMovie">Optional. Filter for movies.</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="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="genreIds">The genres to return guide information for.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableUserData">Optional. include user data.</param> - /// <param name="enableTotalRecordCount">Retrieve total record count.</param> - /// <response code="200">Recommended epgs returned.</response> - /// <returns>A <see cref="OkResult"/> containing the queryresult of recommended epgs.</returns> - [HttpGet("Programs/Recommended")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecommendedPrograms( - [FromQuery] Guid? userId, - [FromQuery] int? limit, - [FromQuery] bool? isAiring, - [FromQuery] bool? hasAired, - [FromQuery] bool? isSeries, - [FromQuery] bool? isMovie, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableUserData, - [FromQuery] bool enableTotalRecordCount = true) + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Gets available live tv epgs. + /// </summary> + /// <param name="body">Request body.</param> + /// <response code="200">Live tv epgs returned.</response> + /// <returns> + /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs. + /// </returns> + [HttpPost("Programs")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.DefaultAuthorization)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms([FromBody] GetProgramsDto body) + { + var user = body.UserId.Equals(default) ? null : _userManager.GetUserById(body.UserId); + + var query = new InternalItemsQuery(user) + { + ChannelIds = body.ChannelIds, + HasAired = body.HasAired, + IsAiring = body.IsAiring, + EnableTotalRecordCount = body.EnableTotalRecordCount, + MinStartDate = body.MinStartDate, + MinEndDate = body.MinEndDate, + MaxStartDate = body.MaxStartDate, + MaxEndDate = body.MaxEndDate, + StartIndex = body.StartIndex, + Limit = body.Limit, + OrderBy = RequestHelpers.GetOrderBy(body.SortBy, body.SortOrder), + IsNews = body.IsNews, + IsMovie = body.IsMovie, + IsSeries = body.IsSeries, + IsKids = body.IsKids, + IsSports = body.IsSports, + SeriesTimerId = body.SeriesTimerId, + Genres = body.Genres, + GenreIds = body.GenreIds + }; + + if (!body.LibrarySeriesId.Equals(default)) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + query.IsSeries = true; - var query = new InternalItemsQuery(user) + if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series) { - IsAiring = isAiring, - Limit = limit, - HasAired = hasAired, - IsSeries = isSeries, - IsMovie = isMovie, - IsKids = isKids, - IsNews = isNews, - IsSports = isSports, - EnableTotalRecordCount = enableTotalRecordCount, - GenreIds = genreIds - }; - - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - return await _liveTvManager.GetRecommendedProgramsAsync(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + query.Name = series.Name; + } } - /// <summary> - /// Gets a live tv program. - /// </summary> - /// <param name="programId">Program id.</param> - /// <param name="userId">Optional. Attach user data.</param> - /// <response code="200">Program returned.</response> - /// <returns>An <see cref="OkResult"/> containing the livetv program.</returns> - [HttpGet("Programs/{programId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<BaseItemDto>> GetProgram( - [FromRoute, Required] string programId, - [FromQuery] Guid? userId) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = body.Fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes); + return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + } - return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); - } + /// <summary> + /// Gets recommended live tv epgs. + /// </summary> + /// <param name="userId">Optional. filter by user id.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param> + /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param> + /// <param name="isSeries">Optional. Filter for series.</param> + /// <param name="isMovie">Optional. Filter for movies.</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="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="genreIds">The genres to return guide information for.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableUserData">Optional. include user data.</param> + /// <param name="enableTotalRecordCount">Retrieve total record count.</param> + /// <response code="200">Recommended epgs returned.</response> + /// <returns>A <see cref="OkResult"/> containing the queryresult of recommended epgs.</returns> + [HttpGet("Programs/Recommended")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetRecommendedPrograms( + [FromQuery] Guid? userId, + [FromQuery] int? limit, + [FromQuery] bool? isAiring, + [FromQuery] bool? hasAired, + [FromQuery] bool? isSeries, + [FromQuery] bool? isMovie, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableUserData, + [FromQuery] bool enableTotalRecordCount = true) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - /// <summary> - /// Deletes a live tv recording. - /// </summary> - /// <param name="recordingId">Recording id.</param> - /// <response code="204">Recording deleted.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> - [HttpDelete("Recordings/{recordingId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteRecording([FromRoute, Required] Guid recordingId) + var query = new InternalItemsQuery(user) { - await AssertUserCanManageLiveTv().ConfigureAwait(false); + IsAiring = isAiring, + Limit = limit, + HasAired = hasAired, + IsSeries = isSeries, + IsMovie = isMovie, + IsKids = isKids, + IsNews = isNews, + IsSports = isSports, + EnableTotalRecordCount = enableTotalRecordCount, + GenreIds = genreIds + }; + + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return await _liveTvManager.GetRecommendedProgramsAsync(query, dtoOptions, CancellationToken.None).ConfigureAwait(false); + } - var item = _libraryManager.GetItemById(recordingId); - if (item is null) - { - return NotFound(); - } + /// <summary> + /// Gets a live tv program. + /// </summary> + /// <param name="programId">Program id.</param> + /// <param name="userId">Optional. Attach user data.</param> + /// <response code="200">Program returned.</response> + /// <returns>An <see cref="OkResult"/> containing the livetv program.</returns> + [HttpGet("Programs/{programId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<BaseItemDto>> GetProgram( + [FromRoute, Required] string programId, + [FromQuery] Guid? userId) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - _libraryManager.DeleteItem(item, new DeleteOptions - { - DeleteFileLocation = false - }); + return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false); + } - return NoContent(); - } + /// <summary> + /// Deletes a live tv recording. + /// </summary> + /// <param name="recordingId">Recording id.</param> + /// <response code="204">Recording deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns> + [HttpDelete("Recordings/{recordingId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteRecording([FromRoute, Required] Guid recordingId) + { + await AssertUserCanManageLiveTv().ConfigureAwait(false); - /// <summary> - /// Cancels a live tv timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <response code="204">Timer deleted.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Timers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CancelTimer([FromRoute, Required] string timerId) + var item = _libraryManager.GetItemById(recordingId); + if (item is null) { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false); - return NoContent(); + return NotFound(); } - /// <summary> - /// Updates a live tv timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <param name="timerInfo">New timer info.</param> - /// <response code="204">Timer updated.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Timers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo) + _libraryManager.DeleteItem(item, new DeleteOptions { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + DeleteFileLocation = false + }); - /// <summary> - /// Creates a live tv timer. - /// </summary> - /// <param name="timerInfo">New timer info.</param> - /// <response code="204">Timer created.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Timers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CreateTimer([FromBody] TimerInfoDto timerInfo) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + return NoContent(); + } - /// <summary> - /// Gets a live tv series timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <response code="200">Series timer returned.</response> - /// <response code="404">Series timer not found.</response> - /// <returns>A <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if timer not found.</returns> - [HttpGet("SeriesTimers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<SeriesTimerInfoDto>> GetSeriesTimer([FromRoute, Required] string timerId) - { - var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false); - if (timer is null) - { - return NotFound(); - } + /// <summary> + /// Cancels a live tv timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="204">Timer deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Timers/{timerId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CancelTimer([FromRoute, Required] string timerId) + { + await AssertUserCanManageLiveTv().ConfigureAwait(false); + await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false); + return NoContent(); + } - return timer; - } + /// <summary> + /// Updates a live tv timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <param name="timerInfo">New timer info.</param> + /// <response code="204">Timer updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Timers/{timerId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo) + { + await AssertUserCanManageLiveTv().ConfigureAwait(false); + await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Gets live tv series timers. - /// </summary> - /// <param name="sortBy">Optional. Sort by SortName or Priority.</param> - /// <param name="sortOrder">Optional. Sort in Ascending or Descending order.</param> - /// <response code="200">Timers returned.</response> - /// <returns>An <see cref="OkResult"/> of live tv series timers.</returns> - [HttpGet("SeriesTimers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<SeriesTimerInfoDto>>> GetSeriesTimers([FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder) - { - return await _liveTvManager.GetSeriesTimers( - new SeriesTimerQuery - { - SortOrder = sortOrder ?? SortOrder.Ascending, - SortBy = sortBy - }, - CancellationToken.None).ConfigureAwait(false); - } + /// <summary> + /// Creates a live tv timer. + /// </summary> + /// <param name="timerInfo">New timer info.</param> + /// <response code="204">Timer created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Timers")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CreateTimer([FromBody] TimerInfoDto timerInfo) + { + await AssertUserCanManageLiveTv().ConfigureAwait(false); + await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Cancels a live tv series timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <response code="204">Timer cancelled.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("SeriesTimers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CancelSeriesTimer([FromRoute, Required] string timerId) + /// <summary> + /// Gets a live tv series timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="200">Series timer returned.</response> + /// <response code="404">Series timer not found.</response> + /// <returns>A <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if timer not found.</returns> + [HttpGet("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<SeriesTimerInfoDto>> GetSeriesTimer([FromRoute, Required] string timerId) + { + var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false); + if (timer is null) { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false); - return NoContent(); + return NotFound(); } - /// <summary> - /// Updates a live tv series timer. - /// </summary> - /// <param name="timerId">Timer id.</param> - /// <param name="seriesTimerInfo">New series timer info.</param> - /// <response code="204">Series timer updated.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("SeriesTimers/{timerId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] - public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + return timer; + } - /// <summary> - /// Creates a live tv series timer. - /// </summary> - /// <param name="seriesTimerInfo">New series timer info.</param> - /// <response code="204">Series timer info created.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("SeriesTimers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo) - { - await AssertUserCanManageLiveTv().ConfigureAwait(false); - await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); - return NoContent(); - } + /// <summary> + /// Gets live tv series timers. + /// </summary> + /// <param name="sortBy">Optional. Sort by SortName or Priority.</param> + /// <param name="sortOrder">Optional. Sort in Ascending or Descending order.</param> + /// <response code="200">Timers returned.</response> + /// <returns>An <see cref="OkResult"/> of live tv series timers.</returns> + [HttpGet("SeriesTimers")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<SeriesTimerInfoDto>>> GetSeriesTimers([FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder) + { + return await _liveTvManager.GetSeriesTimers( + new SeriesTimerQuery + { + SortOrder = sortOrder ?? SortOrder.Ascending, + SortBy = sortBy + }, + CancellationToken.None).ConfigureAwait(false); + } - /// <summary> - /// Get recording group. - /// </summary> - /// <param name="groupId">Group id.</param> - /// <returns>A <see cref="NotFoundResult"/>.</returns> - [HttpGet("Recordings/Groups/{groupId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("This endpoint is obsolete.")] - public ActionResult<BaseItemDto> GetRecordingGroup([FromRoute, Required] Guid groupId) - { - return NotFound(); - } + /// <summary> + /// Cancels a live tv series timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <response code="204">Timer cancelled.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CancelSeriesTimer([FromRoute, Required] string timerId) + { + await AssertUserCanManageLiveTv().ConfigureAwait(false); + await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Get guid info. - /// </summary> - /// <response code="200">Guid info returned.</response> - /// <returns>An <see cref="OkResult"/> containing the guide info.</returns> - [HttpGet("GuideInfo")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<GuideInfo> GetGuideInfo() - { - return _liveTvManager.GetGuideInfo(); - } + /// <summary> + /// Updates a live tv series timer. + /// </summary> + /// <param name="timerId">Timer id.</param> + /// <param name="seriesTimerInfo">New series timer info.</param> + /// <response code="204">Series timer updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("SeriesTimers/{timerId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")] + public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo) + { + await AssertUserCanManageLiveTv().ConfigureAwait(false); + await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Adds a tuner host. - /// </summary> - /// <param name="tunerHostInfo">New tuner host.</param> - /// <response code="200">Created tuner host returned.</response> - /// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns> - [HttpPost("TunerHosts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo) - { - return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false); - } + /// <summary> + /// Creates a live tv series timer. + /// </summary> + /// <param name="seriesTimerInfo">New series timer info.</param> + /// <response code="204">Series timer info created.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("SeriesTimers")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo) + { + await AssertUserCanManageLiveTv().ConfigureAwait(false); + await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Deletes a tuner host. - /// </summary> - /// <param name="id">Tuner host id.</param> - /// <response code="204">Tuner host deleted.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("TunerHosts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult DeleteTunerHost([FromQuery] string? id) - { - var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); - config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); - _configurationManager.SaveConfiguration("livetv", config); - return NoContent(); - } + /// <summary> + /// Get recording group. + /// </summary> + /// <param name="groupId">Group id.</param> + /// <returns>A <see cref="NotFoundResult"/>.</returns> + [HttpGet("Recordings/Groups/{groupId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("This endpoint is obsolete.")] + public ActionResult<BaseItemDto> GetRecordingGroup([FromRoute, Required] Guid groupId) + { + return NotFound(); + } - /// <summary> - /// Gets default listings provider info. - /// </summary> - /// <response code="200">Default listings provider info returned.</response> - /// <returns>An <see cref="OkResult"/> containing the default listings provider info.</returns> - [HttpGet("ListingProviders/Default")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<ListingsProviderInfo> GetDefaultListingProvider() - { - return new ListingsProviderInfo(); - } + /// <summary> + /// Get guid info. + /// </summary> + /// <response code="200">Guid info returned.</response> + /// <returns>An <see cref="OkResult"/> containing the guide info.</returns> + [HttpGet("GuideInfo")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<GuideInfo> GetGuideInfo() + { + return _liveTvManager.GetGuideInfo(); + } - /// <summary> - /// Adds a listings provider. - /// </summary> - /// <param name="pw">Password.</param> - /// <param name="listingsProviderInfo">New listings info.</param> - /// <param name="validateListings">Validate listings.</param> - /// <param name="validateLogin">Validate login.</param> - /// <response code="200">Created listings provider returned.</response> - /// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns> - [HttpPost("ListingProviders")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")] - public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider( - [FromQuery] string? pw, - [FromBody] ListingsProviderInfo listingsProviderInfo, - [FromQuery] bool validateListings = false, - [FromQuery] bool validateLogin = false) - { - if (!string.IsNullOrEmpty(pw)) - { - // TODO: remove ToLower when Convert.ToHexString supports lowercase - // Schedules Direct requires the hex to be lowercase - listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); - } + /// <summary> + /// Adds a tuner host. + /// </summary> + /// <param name="tunerHostInfo">New tuner host.</param> + /// <response code="200">Created tuner host returned.</response> + /// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns> + [HttpPost("TunerHosts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo) + { + return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false); + } - return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); - } + /// <summary> + /// Deletes a tuner host. + /// </summary> + /// <param name="id">Tuner host id.</param> + /// <response code="204">Tuner host deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("TunerHosts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteTunerHost([FromQuery] string? id) + { + var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); + config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray(); + _configurationManager.SaveConfiguration("livetv", config); + return NoContent(); + } - /// <summary> - /// Delete listing provider. - /// </summary> - /// <param name="id">Listing provider id.</param> - /// <response code="204">Listing provider deleted.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("ListingProviders")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult DeleteListingProvider([FromQuery] string? id) - { - _liveTvManager.DeleteListingsProvider(id); - return NoContent(); - } + /// <summary> + /// Gets default listings provider info. + /// </summary> + /// <response code="200">Default listings provider info returned.</response> + /// <returns>An <see cref="OkResult"/> containing the default listings provider info.</returns> + [HttpGet("ListingProviders/Default")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<ListingsProviderInfo> GetDefaultListingProvider() + { + return new ListingsProviderInfo(); + } - /// <summary> - /// Gets available lineups. - /// </summary> - /// <param name="id">Provider id.</param> - /// <param name="type">Provider type.</param> - /// <param name="location">Location.</param> - /// <param name="country">Country.</param> - /// <response code="200">Available lineups returned.</response> - /// <returns>A <see cref="OkResult"/> containing the available lineups.</returns> - [HttpGet("ListingProviders/Lineups")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<IEnumerable<NameIdPair>>> GetLineups( - [FromQuery] string? id, - [FromQuery] string? type, - [FromQuery] string? location, - [FromQuery] string? country) + /// <summary> + /// Adds a listings provider. + /// </summary> + /// <param name="pw">Password.</param> + /// <param name="listingsProviderInfo">New listings info.</param> + /// <param name="validateListings">Validate listings.</param> + /// <param name="validateLogin">Validate login.</param> + /// <response code="200">Created listings provider returned.</response> + /// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns> + [HttpPost("ListingProviders")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")] + public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider( + [FromQuery] string? pw, + [FromBody] ListingsProviderInfo listingsProviderInfo, + [FromQuery] bool validateListings = false, + [FromQuery] bool validateLogin = false) + { + if (!string.IsNullOrEmpty(pw)) { - return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); + // TODO: remove ToLower when Convert.ToHexString supports lowercase + // Schedules Direct requires the hex to be lowercase + listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); } - /// <summary> - /// Gets available countries. - /// </summary> - /// <response code="200">Available countries returned.</response> - /// <returns>A <see cref="FileResult"/> containing the available countries.</returns> - [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); - // https://json.schedulesdirect.org/20141201/available/countries - // Can't dispose the response as it's required up the call chain. - var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries")) - .ConfigureAwait(false); + return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); + } - return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json); - } + /// <summary> + /// Delete listing provider. + /// </summary> + /// <param name="id">Listing provider id.</param> + /// <response code="204">Listing provider deleted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("ListingProviders")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult DeleteListingProvider([FromQuery] string? id) + { + _liveTvManager.DeleteListingsProvider(id); + return NoContent(); + } - /// <summary> - /// Get channel mapping options. - /// </summary> - /// <param name="providerId">Provider id.</param> - /// <response code="200">Channel mapping options returned.</response> - /// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns> - [HttpGet("ChannelMappingOptions")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<ChannelMappingOptionsDto>> GetChannelMappingOptions([FromQuery] string? providerId) - { - var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); + /// <summary> + /// Gets available lineups. + /// </summary> + /// <param name="id">Provider id.</param> + /// <param name="type">Provider type.</param> + /// <param name="location">Location.</param> + /// <param name="country">Country.</param> + /// <response code="200">Available lineups returned.</response> + /// <returns>A <see cref="OkResult"/> containing the available lineups.</returns> + [HttpGet("ListingProviders/Lineups")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<IEnumerable<NameIdPair>>> GetLineups( + [FromQuery] string? id, + [FromQuery] string? type, + [FromQuery] string? location, + [FromQuery] string? country) + { + return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); + } - var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); + /// <summary> + /// Gets available countries. + /// </summary> + /// <response code="200">Available countries returned.</response> + /// <returns>A <see cref="FileResult"/> containing the available countries.</returns> + [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); + // https://json.schedulesdirect.org/20141201/available/countries + // Can't dispose the response as it's required up the call chain. + var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries")) + .ConfigureAwait(false); - var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; + return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json); + } - var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) - .ConfigureAwait(false); + /// <summary> + /// Get channel mapping options. + /// </summary> + /// <param name="providerId">Provider id.</param> + /// <response code="200">Channel mapping options returned.</response> + /// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns> + [HttpGet("ChannelMappingOptions")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<ChannelMappingOptionsDto>> GetChannelMappingOptions([FromQuery] string? providerId) + { + var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); - var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) - .ConfigureAwait(false); + var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); - var mappings = listingsProviderInfo.ChannelMappings; + var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; - return new ChannelMappingOptionsDto - { - TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), - ProviderChannels = providerChannels.Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Id - }).ToList(), - Mappings = mappings, - ProviderName = listingsProviderName - }; - } + var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) + .ConfigureAwait(false); - /// <summary> - /// Set channel mappings. - /// </summary> - /// <param name="setChannelMappingDto">The set channel mapping dto.</param> - /// <response code="200">Created channel mapping returned.</response> - /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns> - [HttpPost("ChannelMappings")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) - { - return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); - } + var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) + .ConfigureAwait(false); - /// <summary> - /// Get tuner host types. - /// </summary> - /// <response code="200">Tuner host types returned.</response> - /// <returns>An <see cref="OkResult"/> containing the tuner host types.</returns> - [HttpGet("TunerHosts/Types")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<NameIdPair>> GetTunerHostTypes() - { - return _liveTvManager.GetTunerHostTypes(); - } + var mappings = listingsProviderInfo.ChannelMappings; - /// <summary> - /// Discover tuners. - /// </summary> - /// <param name="newDevicesOnly">Only discover new tuners.</param> - /// <response code="200">Tuners returned.</response> - /// <returns>An <see cref="OkResult"/> containing the tuners.</returns> - [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")] - [HttpGet("Tuners/Discover")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) + return new ChannelMappingOptionsDto { - return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false); - } + TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), + ProviderChannels = providerChannels.Select(i => new NameIdPair + { + Name = i.Name, + Id = i.Id + }).ToList(), + Mappings = mappings, + ProviderName = listingsProviderName + }; + } - /// <summary> - /// Gets a live tv recording stream. - /// </summary> - /// <param name="recordingId">Recording id.</param> - /// <response code="200">Recording stream returned.</response> - /// <response code="404">Recording not found.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the recording stream on success, - /// or a <see cref="NotFoundResult"/> if recording not found. - /// </returns> - [HttpGet("LiveRecordings/{recordingId}/stream")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesVideoFile] - public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) - { - var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); + /// <summary> + /// Set channel mappings. + /// </summary> + /// <param name="setChannelMappingDto">The set channel mapping dto.</param> + /// <response code="200">Created channel mapping returned.</response> + /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns> + [HttpPost("ChannelMappings")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) + { + return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); + } - if (string.IsNullOrWhiteSpace(path)) - { - return NotFound(); - } + /// <summary> + /// Get tuner host types. + /// </summary> + /// <response code="200">Tuner host types returned.</response> + /// <returns>An <see cref="OkResult"/> containing the tuner host types.</returns> + [HttpGet("TunerHosts/Types")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<NameIdPair>> GetTunerHostTypes() + { + return _liveTvManager.GetTunerHostTypes(); + } - var stream = new ProgressiveFileStream(path, null, _transcodingJobHelper); - return new FileStreamResult(stream, MimeTypes.GetMimeType(path)); - } + /// <summary> + /// Discover tuners. + /// </summary> + /// <param name="newDevicesOnly">Only discover new tuners.</param> + /// <response code="200">Tuners returned.</response> + /// <returns>An <see cref="OkResult"/> containing the tuners.</returns> + [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")] + [HttpGet("Tuners/Discover")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false) + { + return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false); + } - /// <summary> - /// Gets a live tv channel stream. - /// </summary> - /// <param name="streamId">Stream id.</param> - /// <param name="container">Container type.</param> - /// <response code="200">Stream returned.</response> - /// <response code="404">Stream not found.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the channel stream on success, - /// or a <see cref="NotFoundResult"/> if stream not found. - /// </returns> - [HttpGet("LiveStreamFiles/{streamId}/stream.{container}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesVideoFile] - public ActionResult GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container) + /// <summary> + /// Gets a live tv recording stream. + /// </summary> + /// <param name="recordingId">Recording id.</param> + /// <response code="200">Recording stream returned.</response> + /// <response code="404">Recording not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the recording stream on success, + /// or a <see cref="NotFoundResult"/> if recording not found. + /// </returns> + [HttpGet("LiveRecordings/{recordingId}/stream")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesVideoFile] + public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) + { + var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); + + if (string.IsNullOrWhiteSpace(path)) { - var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId); - if (liveStreamInfo is null) - { - return NotFound(); - } + return NotFound(); + } + + var stream = new ProgressiveFileStream(path, null, _transcodingJobHelper); + return new FileStreamResult(stream, MimeTypes.GetMimeType(path)); + } - var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); - return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container)); + /// <summary> + /// Gets a live tv channel stream. + /// </summary> + /// <param name="streamId">Stream id.</param> + /// <param name="container">Container type.</param> + /// <response code="200">Stream returned.</response> + /// <response code="404">Stream not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the channel stream on success, + /// or a <see cref="NotFoundResult"/> if stream not found. + /// </returns> + [HttpGet("LiveStreamFiles/{streamId}/stream.{container}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesVideoFile] + public ActionResult GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container) + { + var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId); + if (liveStreamInfo is null) + { + return NotFound(); } - private async Task AssertUserCanManageLiveTv() + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); + return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container)); + } + + private async Task AssertUserCanManageLiveTv() + { + var user = _userManager.GetUserById(User.GetUserId()); + var session = await _sessionManager.LogSessionActivity( + User.GetClient(), + User.GetVersion(), + User.GetDeviceId(), + User.GetDevice(), + HttpContext.GetNormalizedRemoteIp().ToString(), + user).ConfigureAwait(false); + + if (session.UserId.Equals(default)) { - var user = _userManager.GetUserById(User.GetUserId()); - var session = await _sessionManager.LogSessionActivity( - User.GetClient(), - User.GetVersion(), - User.GetDeviceId(), - User.GetDevice(), - HttpContext.GetNormalizedRemoteIp().ToString(), - user).ConfigureAwait(false); - - if (session.UserId.Equals(default)) - { - throw new SecurityException("Anonymous live tv management is not allowed."); - } + throw new SecurityException("Anonymous live tv management is not allowed."); + } - if (!user.HasPermission(PermissionKind.EnableLiveTvManagement)) - { - throw new SecurityException("The current user does not have permission to manage live tv."); - } + if (!user.HasPermission(PermissionKind.EnableLiveTvManagement)) + { + throw new SecurityException("The current user does not have permission to manage live tv."); } } } diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs index 3d8b9e0ca..b9772a069 100644 --- a/Jellyfin.Api/Controllers/LocalizationController.cs +++ b/Jellyfin.Api/Controllers/LocalizationController.cs @@ -6,71 +6,70 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Localization controller. +/// </summary> +[Authorize(Policy = Policies.FirstTimeSetupOrDefault)] +public class LocalizationController : BaseJellyfinApiController { + private readonly ILocalizationManager _localization; + /// <summary> - /// Localization controller. + /// Initializes a new instance of the <see cref="LocalizationController"/> class. /// </summary> - [Authorize(Policy = Policies.FirstTimeSetupOrDefault)] - public class LocalizationController : BaseJellyfinApiController + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + public LocalizationController(ILocalizationManager localization) { - private readonly ILocalizationManager _localization; - - /// <summary> - /// Initializes a new instance of the <see cref="LocalizationController"/> class. - /// </summary> - /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> - public LocalizationController(ILocalizationManager localization) - { - _localization = localization; - } + _localization = localization; + } - /// <summary> - /// Gets known cultures. - /// </summary> - /// <response code="200">Known cultures returned.</response> - /// <returns>An <see cref="OkResult"/> containing the list of cultures.</returns> - [HttpGet("Cultures")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<CultureDto>> GetCultures() - { - return Ok(_localization.GetCultures()); - } + /// <summary> + /// Gets known cultures. + /// </summary> + /// <response code="200">Known cultures returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of cultures.</returns> + [HttpGet("Cultures")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<CultureDto>> GetCultures() + { + return Ok(_localization.GetCultures()); + } - /// <summary> - /// Gets known countries. - /// </summary> - /// <response code="200">Known countries returned.</response> - /// <returns>An <see cref="OkResult"/> containing the list of countries.</returns> - [HttpGet("Countries")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<CountryInfo>> GetCountries() - { - return Ok(_localization.GetCountries()); - } + /// <summary> + /// Gets known countries. + /// </summary> + /// <response code="200">Known countries returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of countries.</returns> + [HttpGet("Countries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<CountryInfo>> GetCountries() + { + return Ok(_localization.GetCountries()); + } - /// <summary> - /// Gets known parental ratings. - /// </summary> - /// <response code="200">Known parental ratings returned.</response> - /// <returns>An <see cref="OkResult"/> containing the list of parental ratings.</returns> - [HttpGet("ParentalRatings")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<ParentalRating>> GetParentalRatings() - { - return Ok(_localization.GetParentalRatings()); - } + /// <summary> + /// Gets known parental ratings. + /// </summary> + /// <response code="200">Known parental ratings returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of parental ratings.</returns> + [HttpGet("ParentalRatings")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<ParentalRating>> GetParentalRatings() + { + return Ok(_localization.GetParentalRatings()); + } - /// <summary> - /// Gets localization options. - /// </summary> - /// <response code="200">Localization options returned.</response> - /// <returns>An <see cref="OkResult"/> containing the list of localization options.</returns> - [HttpGet("Options")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<LocalizationOption>> GetLocalizationOptions() - { - return Ok(_localization.GetLocalizationOptions()); - } + /// <summary> + /// Gets localization options. + /// </summary> + /// <response code="200">Localization options returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of localization options.</returns> + [HttpGet("Options")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<LocalizationOption>> GetLocalizationOptions() + { + return Ok(_localization.GetLocalizationOptions()); } } diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 8115c3585..eee7df3af 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -19,295 +19,294 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The media info controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class MediaInfoController : BaseJellyfinApiController { + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IDeviceManager _deviceManager; + private readonly ILibraryManager _libraryManager; + private readonly ILogger<MediaInfoController> _logger; + private readonly MediaInfoHelper _mediaInfoHelper; + /// <summary> - /// The media info controller. + /// Initializes a new instance of the <see cref="MediaInfoController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class MediaInfoController : BaseJellyfinApiController + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param> + /// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param> + public MediaInfoController( + IMediaSourceManager mediaSourceManager, + IDeviceManager deviceManager, + ILibraryManager libraryManager, + ILogger<MediaInfoController> logger, + MediaInfoHelper mediaInfoHelper) { - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IDeviceManager _deviceManager; - private readonly ILibraryManager _libraryManager; - private readonly ILogger<MediaInfoController> _logger; - private readonly MediaInfoHelper _mediaInfoHelper; + _mediaSourceManager = mediaSourceManager; + _deviceManager = deviceManager; + _libraryManager = libraryManager; + _logger = logger; + _mediaInfoHelper = mediaInfoHelper; + } - /// <summary> - /// Initializes a new instance of the <see cref="MediaInfoController"/> class. - /// </summary> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param> - /// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param> - public MediaInfoController( - IMediaSourceManager mediaSourceManager, - IDeviceManager deviceManager, - ILibraryManager libraryManager, - ILogger<MediaInfoController> logger, - MediaInfoHelper mediaInfoHelper) - { - _mediaSourceManager = mediaSourceManager; - _deviceManager = deviceManager; - _libraryManager = libraryManager; - _logger = logger; - _mediaInfoHelper = mediaInfoHelper; - } + /// <summary> + /// Gets live playback media info for an item. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="userId">The user id.</param> + /// <response code="200">Playback info returned.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns> + [HttpGet("Items/{itemId}/PlaybackInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId) + { + return await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId) + .ConfigureAwait(false); + } - /// <summary> - /// Gets live playback media info for an item. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">The user id.</param> - /// <response code="200">Playback info returned.</response> - /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns> - [HttpGet("Items/{itemId}/PlaybackInfo")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId) - { - return await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId) - .ConfigureAwait(false); - } + /// <summary> + /// Gets live playback media info for an item. + /// </summary> + /// <remarks> + /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. + /// Query parameters are obsolete. + /// </remarks> + /// <param name="itemId">The item id.</param> + /// <param name="userId">The user id.</param> + /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="maxAudioChannels">The maximum number of audio channels.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="liveStreamId">The livestream id.</param> + /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param> + /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> + /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> + /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param> + /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param> + /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param> + /// <param name="playbackInfoDto">The playback info.</param> + /// <response code="200">Playback info returned.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns> + [HttpPost("Items/{itemId}/PlaybackInfo")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo( + [FromRoute, Required] Guid itemId, + [FromQuery, ParameterObsolete] Guid? userId, + [FromQuery, ParameterObsolete] int? maxStreamingBitrate, + [FromQuery, ParameterObsolete] long? startTimeTicks, + [FromQuery, ParameterObsolete] int? audioStreamIndex, + [FromQuery, ParameterObsolete] int? subtitleStreamIndex, + [FromQuery, ParameterObsolete] int? maxAudioChannels, + [FromQuery, ParameterObsolete] string? mediaSourceId, + [FromQuery, ParameterObsolete] string? liveStreamId, + [FromQuery, ParameterObsolete] bool? autoOpenLiveStream, + [FromQuery, ParameterObsolete] bool? enableDirectPlay, + [FromQuery, ParameterObsolete] bool? enableDirectStream, + [FromQuery, ParameterObsolete] bool? enableTranscoding, + [FromQuery, ParameterObsolete] bool? allowVideoStreamCopy, + [FromQuery, ParameterObsolete] bool? allowAudioStreamCopy, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto) + { + var profile = playbackInfoDto?.DeviceProfile; + _logger.LogDebug("GetPostedPlaybackInfo profile: {@Profile}", profile); - /// <summary> - /// Gets live playback media info for an item. - /// </summary> - /// <remarks> - /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. - /// Query parameters are obsolete. - /// </remarks> - /// <param name="itemId">The item id.</param> - /// <param name="userId">The user id.</param> - /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> - /// <param name="startTimeTicks">The start time in ticks.</param> - /// <param name="audioStreamIndex">The audio stream index.</param> - /// <param name="subtitleStreamIndex">The subtitle stream index.</param> - /// <param name="maxAudioChannels">The maximum number of audio channels.</param> - /// <param name="mediaSourceId">The media source id.</param> - /// <param name="liveStreamId">The livestream id.</param> - /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param> - /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> - /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> - /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param> - /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param> - /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param> - /// <param name="playbackInfoDto">The playback info.</param> - /// <response code="200">Playback info returned.</response> - /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns> - [HttpPost("Items/{itemId}/PlaybackInfo")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo( - [FromRoute, Required] Guid itemId, - [FromQuery, ParameterObsolete] Guid? userId, - [FromQuery, ParameterObsolete] int? maxStreamingBitrate, - [FromQuery, ParameterObsolete] long? startTimeTicks, - [FromQuery, ParameterObsolete] int? audioStreamIndex, - [FromQuery, ParameterObsolete] int? subtitleStreamIndex, - [FromQuery, ParameterObsolete] int? maxAudioChannels, - [FromQuery, ParameterObsolete] string? mediaSourceId, - [FromQuery, ParameterObsolete] string? liveStreamId, - [FromQuery, ParameterObsolete] bool? autoOpenLiveStream, - [FromQuery, ParameterObsolete] bool? enableDirectPlay, - [FromQuery, ParameterObsolete] bool? enableDirectStream, - [FromQuery, ParameterObsolete] bool? enableTranscoding, - [FromQuery, ParameterObsolete] bool? allowVideoStreamCopy, - [FromQuery, ParameterObsolete] bool? allowAudioStreamCopy, - [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] PlaybackInfoDto? playbackInfoDto) + if (profile is null) { - var profile = playbackInfoDto?.DeviceProfile; - _logger.LogDebug("GetPostedPlaybackInfo profile: {@Profile}", profile); - - if (profile is null) + var caps = _deviceManager.GetCapabilities(User.GetDeviceId()); + if (caps is not null) { - var caps = _deviceManager.GetCapabilities(User.GetDeviceId()); - if (caps is not null) - { - profile = caps.DeviceProfile; - } + profile = caps.DeviceProfile; } + } - // Copy params from posted body - // TODO clean up when breaking API compatibility. - userId ??= playbackInfoDto?.UserId; - maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate; - startTimeTicks ??= playbackInfoDto?.StartTimeTicks; - audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex; - subtitleStreamIndex ??= playbackInfoDto?.SubtitleStreamIndex; - maxAudioChannels ??= playbackInfoDto?.MaxAudioChannels; - mediaSourceId ??= playbackInfoDto?.MediaSourceId; - liveStreamId ??= playbackInfoDto?.LiveStreamId; - autoOpenLiveStream ??= playbackInfoDto?.AutoOpenLiveStream ?? false; - enableDirectPlay ??= playbackInfoDto?.EnableDirectPlay ?? true; - enableDirectStream ??= playbackInfoDto?.EnableDirectStream ?? true; - enableTranscoding ??= playbackInfoDto?.EnableTranscoding ?? true; - allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true; - allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true; + // Copy params from posted body + // TODO clean up when breaking API compatibility. + userId ??= playbackInfoDto?.UserId; + maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate; + startTimeTicks ??= playbackInfoDto?.StartTimeTicks; + audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex; + subtitleStreamIndex ??= playbackInfoDto?.SubtitleStreamIndex; + maxAudioChannels ??= playbackInfoDto?.MaxAudioChannels; + mediaSourceId ??= playbackInfoDto?.MediaSourceId; + liveStreamId ??= playbackInfoDto?.LiveStreamId; + autoOpenLiveStream ??= playbackInfoDto?.AutoOpenLiveStream ?? false; + enableDirectPlay ??= playbackInfoDto?.EnableDirectPlay ?? true; + enableDirectStream ??= playbackInfoDto?.EnableDirectStream ?? true; + enableTranscoding ??= playbackInfoDto?.EnableTranscoding ?? true; + allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true; + allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true; - var info = await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId, - mediaSourceId, - liveStreamId) - .ConfigureAwait(false); + var info = await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId, + mediaSourceId, + liveStreamId) + .ConfigureAwait(false); - if (info.ErrorCode is not null) - { - return info; - } + if (info.ErrorCode is not null) + { + return info; + } + + if (profile is not null) + { + // set device specific data + var item = _libraryManager.GetItemById(itemId); - if (profile is not null) + foreach (var mediaSource in info.MediaSources) { - // set device specific data - var item = _libraryManager.GetItemById(itemId); + _mediaInfoHelper.SetDeviceSpecificData( + item, + mediaSource, + profile, + User, + maxStreamingBitrate ?? profile.MaxStreamingBitrate, + startTimeTicks ?? 0, + mediaSourceId ?? string.Empty, + audioStreamIndex, + subtitleStreamIndex, + maxAudioChannels, + info.PlaySessionId!, + userId ?? Guid.Empty, + enableDirectPlay.Value, + enableDirectStream.Value, + enableTranscoding.Value, + allowVideoStreamCopy.Value, + allowAudioStreamCopy.Value, + Request.HttpContext.GetNormalizedRemoteIp()); + } - foreach (var mediaSource in info.MediaSources) - { - _mediaInfoHelper.SetDeviceSpecificData( - item, - mediaSource, - profile, - User, - maxStreamingBitrate ?? profile.MaxStreamingBitrate, - startTimeTicks ?? 0, - mediaSourceId ?? string.Empty, - audioStreamIndex, - subtitleStreamIndex, - maxAudioChannels, - info.PlaySessionId!, - userId ?? Guid.Empty, - enableDirectPlay.Value, - enableDirectStream.Value, - enableTranscoding.Value, - allowVideoStreamCopy.Value, - allowAudioStreamCopy.Value, - Request.HttpContext.GetNormalizedRemoteIp()); - } + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + } - _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); - } + if (autoOpenLiveStream.Value) + { + var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal)); - if (autoOpenLiveStream.Value) + if (mediaSource is not null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId)) { - var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal)); - - if (mediaSource is not null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId)) - { - var openStreamResult = await _mediaInfoHelper.OpenMediaSource( - HttpContext, - new LiveStreamRequest - { - AudioStreamIndex = audioStreamIndex, - DeviceProfile = playbackInfoDto?.DeviceProfile, - EnableDirectPlay = enableDirectPlay.Value, - EnableDirectStream = enableDirectStream.Value, - ItemId = itemId, - MaxAudioChannels = maxAudioChannels, - MaxStreamingBitrate = maxStreamingBitrate, - PlaySessionId = info.PlaySessionId, - StartTimeTicks = startTimeTicks, - SubtitleStreamIndex = subtitleStreamIndex, - UserId = userId ?? Guid.Empty, - OpenToken = mediaSource.OpenToken - }).ConfigureAwait(false); + var openStreamResult = await _mediaInfoHelper.OpenMediaSource( + HttpContext, + new LiveStreamRequest + { + AudioStreamIndex = audioStreamIndex, + DeviceProfile = playbackInfoDto?.DeviceProfile, + EnableDirectPlay = enableDirectPlay.Value, + EnableDirectStream = enableDirectStream.Value, + ItemId = itemId, + MaxAudioChannels = maxAudioChannels, + MaxStreamingBitrate = maxStreamingBitrate, + PlaySessionId = info.PlaySessionId, + StartTimeTicks = startTimeTicks, + SubtitleStreamIndex = subtitleStreamIndex, + UserId = userId ?? Guid.Empty, + OpenToken = mediaSource.OpenToken + }).ConfigureAwait(false); - info.MediaSources = new[] { openStreamResult.MediaSource }; - } + info.MediaSources = new[] { openStreamResult.MediaSource }; } - - return info; } - /// <summary> - /// Opens a media source. - /// </summary> - /// <param name="openToken">The open token.</param> - /// <param name="userId">The user id.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> - /// <param name="startTimeTicks">The start time in ticks.</param> - /// <param name="audioStreamIndex">The audio stream index.</param> - /// <param name="subtitleStreamIndex">The subtitle stream index.</param> - /// <param name="maxAudioChannels">The maximum number of audio channels.</param> - /// <param name="itemId">The item id.</param> - /// <param name="openLiveStreamDto">The open live stream dto.</param> - /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> - /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> - /// <response code="200">Media source opened.</response> - /// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns> - [HttpPost("LiveStreams/Open")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<LiveStreamResponse>> OpenLiveStream( - [FromQuery] string? openToken, - [FromQuery] Guid? userId, - [FromQuery] string? playSessionId, - [FromQuery] int? maxStreamingBitrate, - [FromQuery] long? startTimeTicks, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] int? maxAudioChannels, - [FromQuery] Guid? itemId, - [FromBody] OpenLiveStreamDto? openLiveStreamDto, - [FromQuery] bool? enableDirectPlay, - [FromQuery] bool? enableDirectStream) + return info; + } + + /// <summary> + /// Opens a media source. + /// </summary> + /// <param name="openToken">The open token.</param> + /// <param name="userId">The user id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="maxAudioChannels">The maximum number of audio channels.</param> + /// <param name="itemId">The item id.</param> + /// <param name="openLiveStreamDto">The open live stream dto.</param> + /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param> + /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param> + /// <response code="200">Media source opened.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns> + [HttpPost("LiveStreams/Open")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<LiveStreamResponse>> OpenLiveStream( + [FromQuery] string? openToken, + [FromQuery] Guid? userId, + [FromQuery] string? playSessionId, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] long? startTimeTicks, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] int? maxAudioChannels, + [FromQuery] Guid? itemId, + [FromBody] OpenLiveStreamDto? openLiveStreamDto, + [FromQuery] bool? enableDirectPlay, + [FromQuery] bool? enableDirectStream) + { + var request = new LiveStreamRequest { - var request = new LiveStreamRequest - { - OpenToken = openToken ?? openLiveStreamDto?.OpenToken, - UserId = userId ?? openLiveStreamDto?.UserId ?? Guid.Empty, - PlaySessionId = playSessionId ?? openLiveStreamDto?.PlaySessionId, - MaxStreamingBitrate = maxStreamingBitrate ?? openLiveStreamDto?.MaxStreamingBitrate, - StartTimeTicks = startTimeTicks ?? openLiveStreamDto?.StartTimeTicks, - AudioStreamIndex = audioStreamIndex ?? openLiveStreamDto?.AudioStreamIndex, - SubtitleStreamIndex = subtitleStreamIndex ?? openLiveStreamDto?.SubtitleStreamIndex, - MaxAudioChannels = maxAudioChannels ?? openLiveStreamDto?.MaxAudioChannels, - ItemId = itemId ?? openLiveStreamDto?.ItemId ?? Guid.Empty, - DeviceProfile = openLiveStreamDto?.DeviceProfile, - EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true, - EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true, - DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http } - }; - return await _mediaInfoHelper.OpenMediaSource(HttpContext, request).ConfigureAwait(false); - } + OpenToken = openToken ?? openLiveStreamDto?.OpenToken, + UserId = userId ?? openLiveStreamDto?.UserId ?? Guid.Empty, + PlaySessionId = playSessionId ?? openLiveStreamDto?.PlaySessionId, + MaxStreamingBitrate = maxStreamingBitrate ?? openLiveStreamDto?.MaxStreamingBitrate, + StartTimeTicks = startTimeTicks ?? openLiveStreamDto?.StartTimeTicks, + AudioStreamIndex = audioStreamIndex ?? openLiveStreamDto?.AudioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex ?? openLiveStreamDto?.SubtitleStreamIndex, + MaxAudioChannels = maxAudioChannels ?? openLiveStreamDto?.MaxAudioChannels, + ItemId = itemId ?? openLiveStreamDto?.ItemId ?? Guid.Empty, + DeviceProfile = openLiveStreamDto?.DeviceProfile, + EnableDirectPlay = enableDirectPlay ?? openLiveStreamDto?.EnableDirectPlay ?? true, + EnableDirectStream = enableDirectStream ?? openLiveStreamDto?.EnableDirectStream ?? true, + DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http } + }; + return await _mediaInfoHelper.OpenMediaSource(HttpContext, request).ConfigureAwait(false); + } - /// <summary> - /// Closes a media source. - /// </summary> - /// <param name="liveStreamId">The livestream id.</param> - /// <response code="204">Livestream closed.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("LiveStreams/Close")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> CloseLiveStream([FromQuery, Required] string liveStreamId) + /// <summary> + /// Closes a media source. + /// </summary> + /// <param name="liveStreamId">The livestream id.</param> + /// <response code="204">Livestream closed.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("LiveStreams/Close")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> CloseLiveStream([FromQuery, Required] string liveStreamId) + { + await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Tests the network with a request with the size of the bitrate. + /// </summary> + /// <param name="size">The bitrate. Defaults to 102400.</param> + /// <response code="200">Test buffer returned.</response> + /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns> + [HttpGet("Playback/BitrateTest")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile(MediaTypeNames.Application.Octet)] + public ActionResult GetBitrateTestBytes([FromQuery][Range(1, 100_000_000, ErrorMessage = "The requested size must be greater than or equal to {1} and less than or equal to {2}")] int size = 102400) + { + byte[] buffer = ArrayPool<byte>.Shared.Rent(size); + try { - await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); - return NoContent(); + Random.Shared.NextBytes(buffer); + return File(buffer, MediaTypeNames.Application.Octet); } - - /// <summary> - /// Tests the network with a request with the size of the bitrate. - /// </summary> - /// <param name="size">The bitrate. Defaults to 102400.</param> - /// <response code="200">Test buffer returned.</response> - /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns> - [HttpGet("Playback/BitrateTest")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile(MediaTypeNames.Application.Octet)] - public ActionResult GetBitrateTestBytes([FromQuery][Range(1, 100_000_000, ErrorMessage = "The requested size must be greater than or equal to {1} and less than or equal to {2}")] int size = 102400) + finally { - byte[] buffer = ArrayPool<byte>.Shared.Rent(size); - try - { - Random.Shared.NextBytes(buffer); - return File(buffer, MediaTypeNames.Application.Octet); - } - finally - { - ArrayPool<byte>.Shared.Return(buffer); - } + ArrayPool<byte>.Shared.Return(buffer); } } } diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 3cf079362..4c30dd2b3 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -18,122 +18,122 @@ using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Movies controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class MoviesController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IServerConfigurationManager _serverConfigurationManager; + /// <summary> - /// Movies controller. + /// Initializes a new instance of the <see cref="MoviesController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class MoviesController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public MoviesController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService, + IServerConfigurationManager serverConfigurationManager) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IServerConfigurationManager _serverConfigurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="MoviesController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public MoviesController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - IServerConfigurationManager serverConfigurationManager) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - _serverConfigurationManager = serverConfigurationManager; - } - - /// <summary> - /// Gets movie recommendations. - /// </summary> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. The fields to return.</param> - /// <param name="categoryLimit">The max number of categories to return.</param> - /// <param name="itemLimit">The max number of items to return per category.</param> - /// <response code="200">Movie recommendations returned.</response> - /// <returns>The list of movie recommendations.</returns> - [HttpGet("Recommendations")] - public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations( - [FromQuery] Guid? userId, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] int categoryLimit = 5, - [FromQuery] int itemLimit = 8) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User); - - var categories = new List<RecommendationDto>(); - - var parentIdGuid = parentId ?? Guid.Empty; + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + _serverConfigurationManager = serverConfigurationManager; + } - var query = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] - { - BaseItemKind.Movie, - // nameof(Trailer), - // nameof(LiveTvProgram) - }, - // IsMovie = true - OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 7, - ParentId = parentIdGuid, - Recursive = true, - IsPlayed = true, - DtoOptions = dtoOptions - }; + /// <summary> + /// Gets movie recommendations. + /// </summary> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. The fields to return.</param> + /// <param name="categoryLimit">The max number of categories to return.</param> + /// <param name="itemLimit">The max number of items to return per category.</param> + /// <response code="200">Movie recommendations returned.</response> + /// <returns>The list of movie recommendations.</returns> + [HttpGet("Recommendations")] + public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations( + [FromQuery] Guid? userId, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] int categoryLimit = 5, + [FromQuery] int itemLimit = 8) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User); - var recentlyPlayedMovies = _libraryManager.GetItemList(query); + var categories = new List<RecommendationDto>(); - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } + var parentIdGuid = parentId ?? Guid.Empty; - var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + var query = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, - Limit = 10, - IsFavoriteOrLiked = true, - ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), - EnableGroupByMetadataKey = true, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = dtoOptions - }); - - var mostRecentMovies = recentlyPlayedMovies.GetRange(0, Math.Min(recentlyPlayedMovies.Count, 6)); - // Get recently played directors - var recentDirectors = GetDirectors(mostRecentMovies) - .ToList(); - - // Get recently played actors - var recentActors = GetActors(mostRecentMovies) - .ToList(); - - var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); - var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); - - var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); - var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); + BaseItemKind.Movie, + // nameof(Trailer), + // nameof(LiveTvProgram) + }, + // IsMovie = true + OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.Random, SortOrder.Descending) }, + Limit = 7, + ParentId = parentIdGuid, + Recursive = true, + IsPlayed = true, + DtoOptions = dtoOptions + }; + + var recentlyPlayedMovies = _libraryManager.GetItemList(query); + + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } - var categoryTypes = new List<IEnumerator<RecommendationDto>> + var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, + Limit = 10, + IsFavoriteOrLiked = true, + ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(), + EnableGroupByMetadataKey = true, + ParentId = parentIdGuid, + Recursive = true, + DtoOptions = dtoOptions + }); + + var mostRecentMovies = recentlyPlayedMovies.GetRange(0, Math.Min(recentlyPlayedMovies.Count, 6)); + // Get recently played directors + var recentDirectors = GetDirectors(mostRecentMovies) + .ToList(); + + // Get recently played actors + var recentActors = GetActors(mostRecentMovies) + .ToList(); + + var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator(); + var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator(); + + var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator(); + var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator(); + + var categoryTypes = new List<IEnumerator<RecommendationDto>> { // Give this extra weight similarToRecentlyPlayed, @@ -146,181 +146,180 @@ namespace Jellyfin.Api.Controllers hasActorFromRecentlyPlayed }; - while (categories.Count < categoryLimit) - { - var allEmpty = true; + while (categories.Count < categoryLimit) + { + var allEmpty = true; - foreach (var category in categoryTypes) + foreach (var category in categoryTypes) + { + if (category.MoveNext()) { - if (category.MoveNext()) - { - categories.Add(category.Current); - allEmpty = false; + categories.Add(category.Current); + allEmpty = false; - if (categories.Count >= categoryLimit) - { - break; - } + if (categories.Count >= categoryLimit) + { + break; } } - - if (allEmpty) - { - break; - } } - return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()); - } - - private IEnumerable<RecommendationDto> GetWithDirector( - User? user, - IEnumerable<string> names, - int itemLimit, - DtoOptions dtoOptions, - RecommendationType type) - { - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + if (allEmpty) { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); + break; } + } - foreach (var name in names) - { - var items = _libraryManager.GetItemList( - new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by IMDb id, since the database doesn't support this yet - Limit = itemLimit + 2, - PersonTypes = new[] { PersonType.Director }, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); - - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); + return Ok(categories.OrderBy(i => i.RecommendationType).AsEnumerable()); + } - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } - } + private IEnumerable<RecommendationDto> GetWithDirector( + User? user, + IEnumerable<string> names, + int itemLimit, + DtoOptions dtoOptions, + RecommendationType type) + { + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); } - private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + foreach (var name in names) { - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) - { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } - - foreach (var name in names) - { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + var items = _libraryManager.GetItemList( + new InternalItemsQuery(user) { Person = name, // Account for duplicates by IMDb id, since the database doesn't support this yet Limit = itemLimit + 2, + PersonTypes = new[] { PersonType.Director }, IncludeItemTypes = itemTypes.ToArray(), IsMovie = true, EnableGroupByMetadataKey = true, DtoOptions = dtoOptions }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) - .Take(itemLimit) - .ToList(); + .Take(itemLimit) + .ToList(); - if (items.Count > 0) - { - var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); + if (items.Count > 0) + { + var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - yield return new RecommendationDto - { - BaselineItemName = name, - CategoryId = name.GetMD5(), - RecommendationType = type, - Items = returnItems - }; - } + yield return new RecommendationDto + { + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = returnItems + }; } } + } + + private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + { + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + { + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } - private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + foreach (var name in names) { - var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; - if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) { - itemTypes.Add(BaseItemKind.Trailer); - itemTypes.Add(BaseItemKind.LiveTvProgram); - } + Person = name, + // Account for duplicates by IMDb id, since the database doesn't support this yet + Limit = itemLimit + 2, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }).DistinctBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) + .Take(itemLimit) + .ToList(); - foreach (var item in baselineItems) + if (items.Count > 0) { - var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Limit = itemLimit, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - SimilarTo = item, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }); + var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user); - if (similar.Count > 0) + yield return new RecommendationDto { - var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - - yield return new RecommendationDto - { - BaselineItemName = item.Name, - CategoryId = item.Id, - RecommendationType = type, - Items = returnItems - }; - } + BaselineItemName = name, + CategoryId = name.GetMD5(), + RecommendationType = type, + Items = returnItems + }; } } + } - private IEnumerable<string> GetActors(IEnumerable<BaseItem> items) + private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type) + { + var itemTypes = new List<BaseItemKind> { BaseItemKind.Movie }; + if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions) { - var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director }) + itemTypes.Add(BaseItemKind.Trailer); + itemTypes.Add(BaseItemKind.LiveTvProgram); + } + + foreach (var item in baselineItems) + { + var similar = _libraryManager.GetItemList(new InternalItemsQuery(user) { - MaxListOrder = 3 + Limit = itemLimit, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + SimilarTo = item, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions }); - var itemIds = items.Select(i => i.Id).ToList(); + if (similar.Count > 0) + { + var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user); - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); + yield return new RecommendationDto + { + BaselineItemName = item.Name, + CategoryId = item.Id, + RecommendationType = type, + Items = returnItems + }; + } } + } - private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items) + private IEnumerable<string> GetActors(IEnumerable<BaseItem> items) + { + var people = _libraryManager.GetPeople(new InternalPeopleQuery(Array.Empty<string>(), new[] { PersonType.Director }) { - var people = _libraryManager.GetPeople(new InternalPeopleQuery( - new[] { PersonType.Director }, - Array.Empty<string>())); + MaxListOrder = 3 + }); - var itemIds = items.Select(i => i.Id).ToList(); + var itemIds = items.Select(i => i.Id).ToList(); - return people - .Where(i => itemIds.Contains(i.ItemId)) - .Select(i => i.Name) - .DistinctNames(); - } + return people + .Where(i => itemIds.Contains(i.ItemId)) + .Select(i => i.Name) + .DistinctNames(); + } + + private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items) + { + var people = _libraryManager.GetPeople(new InternalPeopleQuery( + new[] { PersonType.Director }, + Array.Empty<string>())); + + var itemIds = items.Select(i => i.Id).ToList(); + + return people + .Where(i => itemIds.Contains(i.ItemId)) + .Select(i => i.Name) + .DistinctNames(); } } diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index f4fb5f44a..302f138eb 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -18,181 +18,180 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The music genres controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class MusicGenresController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + /// <summary> - /// The music genres controller. + /// Initializes a new instance of the <see cref="MusicGenresController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class MusicGenresController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> + public MusicGenresController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) { - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; - - /// <summary> - /// Initializes a new instance of the <see cref="MusicGenresController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param> - public MusicGenresController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - } + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } - /// <summary> - /// Gets all music genres from a given item, folder, or the entire library. - /// </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="searchTerm">The search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> - /// <response code="200">Music genres returned.</response> - /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns> - [HttpGet] - [Obsolete("Use GetGenres instead")] - public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool? isFavorite, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); + /// <summary> + /// Gets all music genres from a given item, folder, or the entire library. + /// </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="searchTerm">The search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Optional. Include total record count.</param> + /// <response code="200">Music genres returned.</response> + /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns> + [HttpGet] + [Obsolete("Use GetGenres instead")] + public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool? isFavorite, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes); - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + User? user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - var parentItem = _libraryManager.GetParentItem(parentId, userId); + var parentItem = _libraryManager.GetParentItem(parentId, userId); - var query = new InternalItemsQuery(user) + var query = new InternalItemsQuery(user) + { + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount, + OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) + }; + + if (parentId.HasValue) + { + if (parentItem is Folder) { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount, - OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder) - }; - - if (parentId.HasValue) + query.AncestorIds = new[] { parentId.Value }; + } + else { - if (parentItem is Folder) - { - query.AncestorIds = new[] { parentId.Value }; - } - else - { - query.ItemIds = new[] { parentId.Value }; - } + query.ItemIds = new[] { parentId.Value }; } - - var result = _libraryManager.GetMusicGenres(query); - - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } - /// <summary> - /// Gets a music genre, by name. - /// </summary> - /// <param name="genreName">The genre name.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns> - [HttpGet("{genreName}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) - { - var dtoOptions = new DtoOptions().AddClientFields(User); + var result = _libraryManager.GetMusicGenres(query); - MusicGenre? item; + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + } - if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) - { - item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions, BaseItemKind.MusicGenre); - } - else - { - item = _libraryManager.GetMusicGenre(genreName); - } + /// <summary> + /// Gets a music genre, by name. + /// </summary> + /// <param name="genreName">The genre name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns> + [HttpGet("{genreName}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId) + { + var dtoOptions = new DtoOptions().AddClientFields(User); - if (userId.HasValue && !userId.Value.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); + MusicGenre? item; - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1) + { + item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions, BaseItemKind.MusicGenre); + } + else + { + item = _libraryManager.GetMusicGenre(genreName); + } - return _dtoService.GetBaseItemDto(item, dtoOptions); + if (userId.HasValue && !userId.Value.Equals(default)) + { + var user = _userManager.GetUserById(userId.Value); + + return _dtoService.GetBaseItemDto(item, dtoOptions, user); } - private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) - where T : BaseItem, new() + return _dtoService.GetBaseItemDto(item, dtoOptions); + } + + private T? GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions, BaseItemKind baseItemKind) + where T : BaseItem, new() + { + var result = libraryManager.GetItemList(new InternalItemsQuery { - var result = libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '&'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + Name = name.Replace(BaseItem.SlugChar, '&'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '/'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '/'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - result ??= libraryManager.GetItemList(new InternalItemsQuery - { - Name = name.Replace(BaseItem.SlugChar, '?'), - IncludeItemTypes = new[] { baseItemKind }, - DtoOptions = dtoOptions - }).OfType<T>().FirstOrDefault(); + result ??= libraryManager.GetItemList(new InternalItemsQuery + { + Name = name.Replace(BaseItem.SlugChar, '?'), + IncludeItemTypes = new[] { baseItemKind }, + DtoOptions = dtoOptions + }).OfType<T>().FirstOrDefault(); - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 10f967dcd..3cb3caadb 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -11,157 +11,156 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Package Controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class PackageController : BaseJellyfinApiController { + private readonly IInstallationManager _installationManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + /// <summary> - /// Package Controller. + /// Initializes a new instance of the <see cref="PackageController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PackageController : BaseJellyfinApiController + /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager) { - private readonly IInstallationManager _installationManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - - /// <summary> - /// Initializes a new instance of the <see cref="PackageController"/> class. - /// </summary> - /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager) - { - _installationManager = installationManager; - _serverConfigurationManager = serverConfigurationManager; - } + _installationManager = installationManager; + _serverConfigurationManager = serverConfigurationManager; + } - /// <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> - /// <response code="200">Package retrieved.</response> - /// <returns>A <see cref="PackageInfo"/> containing package information.</returns> - [HttpGet("Packages/{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PackageInfo>> GetPackageInfo( - [FromRoute, Required] string name, - [FromQuery] Guid? assemblyGuid) + /// <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> + /// <response code="200">Package retrieved.</response> + /// <returns>A <see cref="PackageInfo"/> containing package information.</returns> + [HttpGet("Packages/{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PackageInfo>> GetPackageInfo( + [FromRoute, Required] string name, + [FromQuery] Guid? assemblyGuid) + { + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + var result = _installationManager.FilterPackages( + packages, + name, + assemblyGuid ?? default) + .FirstOrDefault(); + + if (result is null) { - var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - var result = _installationManager.FilterPackages( - packages, - name, - assemblyGuid ?? default) - .FirstOrDefault(); - - if (result is null) - { - return NotFound(); - } - - return result; + return NotFound(); } - /// <summary> - /// Gets available packages. - /// </summary> - /// <response code="200">Available packages returned.</response> - /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns> - [HttpGet("Packages")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<IEnumerable<PackageInfo>> GetPackages() - { - IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + return result; + } - return packages; - } + /// <summary> + /// Gets available packages. + /// </summary> + /// <response code="200">Available packages returned.</response> + /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns> + [HttpGet("Packages")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<IEnumerable<PackageInfo>> GetPackages() + { + IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - /// <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> - /// <param name="repositoryUrl">Optional. Specify the repository to install from.</param> - /// <response code="204">Package found.</response> - /// <response code="404">Package not found.</response> - /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns> - [HttpPost("Packages/Installed/{name}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Policy = Policies.RequiresElevation)] - public async Task<ActionResult> InstallPackage( - [FromRoute, Required] string name, - [FromQuery] Guid? assemblyGuid, - [FromQuery] string? version, - [FromQuery] string? repositoryUrl) - { - var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - if (!string.IsNullOrEmpty(repositoryUrl)) - { - packages = packages.Where(p => p.Versions.Any(q => q.RepositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))) - .ToList(); - } - - var package = _installationManager.GetCompatibleVersions( - packages, - name, - assemblyGuid ?? Guid.Empty, - specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version)) - .FirstOrDefault(); - - if (package is null) - { - return NotFound(); - } - - await _installationManager.InstallPackage(package).ConfigureAwait(false); - - return NoContent(); - } + return packages; + } - /// <summary> - /// Cancels a package installation. - /// </summary> - /// <param name="packageId">Installation Id.</param> - /// <response code="204">Installation cancelled.</response> - /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns> - [HttpDelete("Packages/Installing/{packageId}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CancelPackageInstallation( - [FromRoute, Required] Guid packageId) + /// <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> + /// <param name="repositoryUrl">Optional. Specify the repository to install from.</param> + /// <response code="204">Package found.</response> + /// <response code="404">Package not found.</response> + /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns> + [HttpPost("Packages/Installed/{name}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task<ActionResult> InstallPackage( + [FromRoute, Required] string name, + [FromQuery] Guid? assemblyGuid, + [FromQuery] string? version, + [FromQuery] string? repositoryUrl) + { + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + if (!string.IsNullOrEmpty(repositoryUrl)) { - _installationManager.CancelInstallation(packageId); - return NoContent(); + packages = packages.Where(p => p.Versions.Any(q => q.RepositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))) + .ToList(); } - /// <summary> - /// Gets all package repositories. - /// </summary> - /// <response code="200">Package repositories returned.</response> - /// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns> - [HttpGet("Repositories")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories() - { - return Ok(_serverConfigurationManager.Configuration.PluginRepositories.AsEnumerable()); - } + var package = _installationManager.GetCompatibleVersions( + packages, + name, + assemblyGuid ?? Guid.Empty, + specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version)) + .FirstOrDefault(); - /// <summary> - /// Sets the enabled and existing package repositories. - /// </summary> - /// <param name="repositoryInfos">The list of package repositories.</param> - /// <response code="204">Package repositories saved.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Repositories")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos) + if (package is null) { - _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos; - _serverConfigurationManager.SaveConfiguration(); - return NoContent(); + return NotFound(); } + + await _installationManager.InstallPackage(package).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Cancels a package installation. + /// </summary> + /// <param name="packageId">Installation Id.</param> + /// <response code="204">Installation cancelled.</response> + /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns> + [HttpDelete("Packages/Installing/{packageId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CancelPackageInstallation( + [FromRoute, Required] Guid packageId) + { + _installationManager.CancelInstallation(packageId); + return NoContent(); + } + + /// <summary> + /// Gets all package repositories. + /// </summary> + /// <response code="200">Package repositories returned.</response> + /// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns> + [HttpGet("Repositories")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories() + { + return Ok(_serverConfigurationManager.Configuration.PluginRepositories.AsEnumerable()); + } + + /// <summary> + /// Sets the enabled and existing package repositories. + /// </summary> + /// <param name="repositoryInfos">The list of package repositories.</param> + /// <response code="204">Package repositories saved.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Repositories")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos) + { + _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos; + _serverConfigurationManager.SaveConfiguration(); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index 09f7281ec..9fb6da527 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -15,125 +15,124 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Persons controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class PersonsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + /// <summary> - /// Persons controller. + /// Initializes a new instance of the <see cref="PersonsController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PersonsController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public PersonsController( + ILibraryManager libraryManager, + IDtoService dtoService, + IUserManager userManager) { - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + _userManager = userManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="PersonsController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public PersonsController( - ILibraryManager libraryManager, - IDtoService dtoService, - IUserManager userManager) - { - _libraryManager = libraryManager; - _dtoService = dtoService; - _userManager = userManager; - } + /// <summary> + /// Gets all persons. + /// </summary> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="searchTerm">The search term.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="filters">Optional. Specify additional filters to apply.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param> + /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param> + /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param> + /// <param name="userId">User id.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <response code="200">Persons returned.</response> + /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetPersons( + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery] Guid? appearsInItemId, + [FromQuery] Guid? userId, + [FromQuery] bool? enableImages = true) + { + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + User? user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - /// <summary> - /// Gets all persons. - /// </summary> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="searchTerm">The search term.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="filters">Optional. Specify additional filters to apply.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param> - /// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param> - /// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param> - /// <param name="userId">User id.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <response code="200">Persons returned.</response> - /// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetPersons( - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery] Guid? appearsInItemId, - [FromQuery] Guid? userId, - [FromQuery] bool? enableImages = true) + var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite); + var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery( + personTypes, + excludePersonTypes) { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + NameContains = searchTerm, + User = user, + IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, + AppearsInItemId = appearsInItemId ?? Guid.Empty, + Limit = limit ?? 0 + }); - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + return new QueryResult<BaseItemDto>( + peopleItems + .Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)) + .ToArray()); + } - var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite); - var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery( - personTypes, - excludePersonTypes) - { - NameContains = searchTerm, - User = user, - IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, - AppearsInItemId = appearsInItemId ?? Guid.Empty, - Limit = limit ?? 0 - }); + /// <summary> + /// Get person by name. + /// </summary> + /// <param name="name">Person name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Person returned.</response> + /// <response code="404">Person not found.</response> + /// <returns>An <see cref="OkResult"/> containing the person on success, + /// or a <see cref="NotFoundResult"/> if person not found.</returns> + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId) + { + var dtoOptions = new DtoOptions() + .AddClientFields(User); - return new QueryResult<BaseItemDto>( - peopleItems - .Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)) - .ToArray()); + var item = _libraryManager.GetPerson(name); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Get person by name. - /// </summary> - /// <param name="name">Person name.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Person returned.</response> - /// <response code="404">Person not found.</response> - /// <returns>An <see cref="OkResult"/> containing the person on success, - /// or a <see cref="NotFoundResult"/> if person not found.</returns> - [HttpGet("{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId) + if (userId.HasValue && !userId.Value.Equals(default)) { - var dtoOptions = new DtoOptions() - .AddClientFields(User); - - var item = _libraryManager.GetPerson(name); - if (item is null) - { - return NotFound(); - } - - if (userId.HasValue && !userId.Value.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } - - return _dtoService.GetBaseItemDto(item, dtoOptions); + var user = _userManager.GetUserById(userId.Value); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); } + + return _dtoService.GetBaseItemDto(item, dtoOptions); } } diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index e0c565da1..11e589301 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -20,202 +20,201 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Playlists controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class PlaylistsController : BaseJellyfinApiController { + private readonly IPlaylistManager _playlistManager; + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + /// <summary> - /// Playlists controller. + /// Initializes a new instance of the <see cref="PlaylistsController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PlaylistsController : BaseJellyfinApiController + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public PlaylistsController( + IDtoService dtoService, + IPlaylistManager playlistManager, + IUserManager userManager, + ILibraryManager libraryManager) { - private readonly IPlaylistManager _playlistManager; - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - - /// <summary> - /// Initializes a new instance of the <see cref="PlaylistsController"/> class. - /// </summary> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public PlaylistsController( - IDtoService dtoService, - IPlaylistManager playlistManager, - IUserManager userManager, - ILibraryManager libraryManager) - { - _dtoService = dtoService; - _playlistManager = playlistManager; - _userManager = userManager; - _libraryManager = libraryManager; - } + _dtoService = dtoService; + _playlistManager = playlistManager; + _userManager = userManager; + _libraryManager = libraryManager; + } - /// <summary> - /// Creates a new playlist. - /// </summary> - /// <remarks> - /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. - /// Query parameters are obsolete. - /// </remarks> - /// <param name="name">The playlist name.</param> - /// <param name="ids">The item ids.</param> - /// <param name="userId">The user id.</param> - /// <param name="mediaType">The media type.</param> - /// <param name="createPlaylistRequest">The create playlist payload.</param> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist. - /// The task result contains an <see cref="OkResult"/> indicating success. - /// </returns> - [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( - [FromQuery, ParameterObsolete] string? name, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList<Guid> ids, - [FromQuery, ParameterObsolete] Guid? userId, - [FromQuery, ParameterObsolete] string? mediaType, - [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest) + /// <summary> + /// Creates a new playlist. + /// </summary> + /// <remarks> + /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence. + /// Query parameters are obsolete. + /// </remarks> + /// <param name="name">The playlist name.</param> + /// <param name="ids">The item ids.</param> + /// <param name="userId">The user id.</param> + /// <param name="mediaType">The media type.</param> + /// <param name="createPlaylistRequest">The create playlist payload.</param> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to create a playlist. + /// The task result contains an <see cref="OkResult"/> indicating success. + /// </returns> + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( + [FromQuery, ParameterObsolete] string? name, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder)), ParameterObsolete] IReadOnlyList<Guid> ids, + [FromQuery, ParameterObsolete] Guid? userId, + [FromQuery, ParameterObsolete] string? mediaType, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] CreatePlaylistDto? createPlaylistRequest) + { + if (ids.Count == 0) { - if (ids.Count == 0) - { - ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>(); - } - - var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest - { - Name = name ?? createPlaylistRequest?.Name, - ItemIdList = ids, - UserId = userId ?? createPlaylistRequest?.UserId ?? default, - MediaType = mediaType ?? createPlaylistRequest?.MediaType - }).ConfigureAwait(false); - - return result; + ids = createPlaylistRequest?.Ids ?? Array.Empty<Guid>(); } - /// <summary> - /// Adds items to a playlist. - /// </summary> - /// <param name="playlistId">The playlist id.</param> - /// <param name="ids">Item id, comma delimited.</param> - /// <param name="userId">The userId.</param> - /// <response code="204">Items added to playlist.</response> - /// <returns>An <see cref="NoContentResult"/> on success.</returns> - [HttpPost("{playlistId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> AddToPlaylist( - [FromRoute, Required] Guid playlistId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery] Guid? userId) + var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest { - await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false); - return NoContent(); - } + Name = name ?? createPlaylistRequest?.Name, + ItemIdList = ids, + UserId = userId ?? createPlaylistRequest?.UserId ?? default, + MediaType = mediaType ?? createPlaylistRequest?.MediaType + }).ConfigureAwait(false); - /// <summary> - /// Moves a playlist item. - /// </summary> - /// <param name="playlistId">The playlist id.</param> - /// <param name="itemId">The item id.</param> - /// <param name="newIndex">The new index.</param> - /// <response code="204">Item moved to new index.</response> - /// <returns>An <see cref="NoContentResult"/> on success.</returns> - [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> MoveItem( - [FromRoute, Required] string playlistId, - [FromRoute, Required] string itemId, - [FromRoute, Required] int newIndex) - { - await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false); - return NoContent(); - } + return result; + } - /// <summary> - /// Removes items from a playlist. - /// </summary> - /// <param name="playlistId">The playlist id.</param> - /// <param name="entryIds">The item ids, comma delimited.</param> - /// <response code="204">Items removed.</response> - /// <returns>An <see cref="NoContentResult"/> on success.</returns> - [HttpDelete("{playlistId}/Items")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveFromPlaylist( - [FromRoute, Required] string playlistId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) - { - await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); - return NoContent(); - } + /// <summary> + /// Adds items to a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="ids">Item id, comma delimited.</param> + /// <param name="userId">The userId.</param> + /// <response code="204">Items added to playlist.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> AddToPlaylist( + [FromRoute, Required] Guid playlistId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery] Guid? userId) + { + await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Gets the original items of a playlist. - /// </summary> - /// <param name="playlistId">The playlist id.</param> - /// <param name="userId">User id.</param> - /// <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="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <response code="200">Original playlist returned.</response> - /// <response code="404">Playlist not found.</response> - /// <returns>The original playlist items.</returns> - [HttpGet("{playlistId}/Items")] - public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems( - [FromRoute, Required] Guid playlistId, - [FromQuery, Required] Guid userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? enableImages, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) - { - var playlist = (Playlist)_libraryManager.GetItemById(playlistId); - if (playlist is null) - { - return NotFound(); - } + /// <summary> + /// Moves a playlist item. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="newIndex">The new index.</param> + /// <response code="204">Item moved to new index.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> MoveItem( + [FromRoute, Required] string playlistId, + [FromRoute, Required] string itemId, + [FromRoute, Required] int newIndex) + { + await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false); + return NoContent(); + } - var user = userId.Equals(default) - ? null - : _userManager.GetUserById(userId); + /// <summary> + /// Removes items from a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="entryIds">The item ids, comma delimited.</param> + /// <response code="204">Items removed.</response> + /// <returns>An <see cref="NoContentResult"/> on success.</returns> + [HttpDelete("{playlistId}/Items")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> RemoveFromPlaylist( + [FromRoute, Required] string playlistId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) + { + await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); + return NoContent(); + } - var items = playlist.GetManageableItems().ToArray(); + /// <summary> + /// Gets the original items of a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="userId">User id.</param> + /// <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="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <response code="200">Original playlist returned.</response> + /// <response code="404">Playlist not found.</response> + /// <returns>The original playlist items.</returns> + [HttpGet("{playlistId}/Items")] + public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems( + [FromRoute, Required] Guid playlistId, + [FromQuery, Required] Guid userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? enableImages, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) + { + var playlist = (Playlist)_libraryManager.GetItemById(playlistId); + if (playlist is null) + { + return NotFound(); + } - var count = items.Length; + var user = userId.Equals(default) + ? null + : _userManager.GetUserById(userId); - if (startIndex.HasValue) - { - items = items.Skip(startIndex.Value).ToArray(); - } + var items = playlist.GetManageableItems().ToArray(); - if (limit.HasValue) - { - items = items.Take(limit.Value).ToArray(); - } + var count = items.Length; - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + if (startIndex.HasValue) + { + items = items.Skip(startIndex.Value).ToArray(); + } - var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); + if (limit.HasValue) + { + items = items.Take(limit.Value).ToArray(); + } - for (int index = 0; index < dtos.Count; index++) - { - dtos[index].PlaylistItemId = items[index].Item1.Id; - } + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var result = new QueryResult<BaseItemDto>( - startIndex, - count, - dtos); + var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user); - return result; + for (int index = 0; index < dtos.Count; index++) + { + dtos[index].PlaylistItemId = items[index].Item1.Id; } + + var result = new QueryResult<BaseItemDto>( + startIndex, + count, + dtos); + + return result; } } diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 0260f9e2a..18d6ebf1e 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -17,366 +17,365 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Playstate controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class PlaystateController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataRepository; + private readonly ILibraryManager _libraryManager; + private readonly ISessionManager _sessionManager; + private readonly ILogger<PlaystateController> _logger; + private readonly TranscodingJobHelper _transcodingJobHelper; + /// <summary> - /// Playstate controller. + /// Initializes a new instance of the <see cref="PlaystateController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PlaystateController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + /// <param name="transcodingJobHelper">Th <see cref="TranscodingJobHelper"/> singleton.</param> + public PlaystateController( + IUserManager userManager, + IUserDataManager userDataRepository, + ILibraryManager libraryManager, + ISessionManager sessionManager, + ILoggerFactory loggerFactory, + TranscodingJobHelper transcodingJobHelper) { - private readonly IUserManager _userManager; - private readonly IUserDataManager _userDataRepository; - private readonly ILibraryManager _libraryManager; - private readonly ISessionManager _sessionManager; - private readonly ILogger<PlaystateController> _logger; - private readonly TranscodingJobHelper _transcodingJobHelper; + _userManager = userManager; + _userDataRepository = userDataRepository; + _libraryManager = libraryManager; + _sessionManager = sessionManager; + _logger = loggerFactory.CreateLogger<PlaystateController>(); - /// <summary> - /// Initializes a new instance of the <see cref="PlaystateController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> - /// <param name="transcodingJobHelper">Th <see cref="TranscodingJobHelper"/> singleton.</param> - public PlaystateController( - IUserManager userManager, - IUserDataManager userDataRepository, - ILibraryManager libraryManager, - ISessionManager sessionManager, - ILoggerFactory loggerFactory, - TranscodingJobHelper transcodingJobHelper) - { - _userManager = userManager; - _userDataRepository = userDataRepository; - _libraryManager = libraryManager; - _sessionManager = sessionManager; - _logger = loggerFactory.CreateLogger<PlaystateController>(); + _transcodingJobHelper = transcodingJobHelper; + } - _transcodingJobHelper = transcodingJobHelper; + /// <summary> + /// Marks an item as played for user. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="datePlayed">Optional. The date the item was played.</param> + /// <response code="200">Item marked as played.</response> + /// <response code="404">Item not found.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns> + [HttpPost("Users/{userId}/PlayedItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<UserItemDataDto>> MarkPlayedItem( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId, + [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) + { + var user = _userManager.GetUserById(userId); + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Marks an item as played for user. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <param name="datePlayed">Optional. The date the item was played.</param> - /// <response code="200">Item marked as played.</response> - /// <response code="404">Item not found.</response> - /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns> - [HttpPost("Users/{userId}/PlayedItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<UserItemDataDto>> MarkPlayedItem( - [FromRoute, Required] Guid userId, - [FromRoute, Required] Guid itemId, - [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) + var dto = UpdatePlayedStatus(user, item, true, datePlayed); + foreach (var additionalUserInfo in session.AdditionalUsers) { - var user = _userManager.GetUserById(userId); - var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); + UpdatePlayedStatus(additionalUser, item, true, datePlayed); + } - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + return dto; + } - var dto = UpdatePlayedStatus(user, item, true, datePlayed); - foreach (var additionalUserInfo in session.AdditionalUsers) - { - var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); - UpdatePlayedStatus(additionalUser, item, true, datePlayed); - } + /// <summary> + /// Marks an item as unplayed for user. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item marked as unplayed.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns> + [HttpDelete("Users/{userId}/PlayedItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var item = _libraryManager.GetItemById(itemId); - return dto; + if (item is null) + { + return NotFound(); } - /// <summary> - /// Marks an item as unplayed for user. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Item marked as unplayed.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>, or a <see cref="NotFoundResult"/> if item was not found.</returns> - [HttpDelete("Users/{userId}/PlayedItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<UserItemDataDto>> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + var dto = UpdatePlayedStatus(user, item, false, null); + foreach (var additionalUserInfo in session.AdditionalUsers) { - var user = _userManager.GetUserById(userId); - var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var item = _libraryManager.GetItemById(itemId); + var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); + UpdatePlayedStatus(additionalUser, item, false, null); + } - if (item is null) - { - return NotFound(); - } + return dto; + } - var dto = UpdatePlayedStatus(user, item, false, null); - foreach (var additionalUserInfo in session.AdditionalUsers) - { - var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId); - UpdatePlayedStatus(additionalUser, item, false, null); - } + /// <summary> + /// Reports playback has started within a session. + /// </summary> + /// <param name="playbackStartInfo">The playback start info.</param> + /// <response code="204">Playback start recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo) + { + playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); + playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); + return NoContent(); + } - return dto; - } + /// <summary> + /// Reports playback progress within a session. + /// </summary> + /// <param name="playbackProgressInfo">The playback progress info.</param> + /// <response code="204">Playback progress recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing/Progress")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo) + { + playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); + playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Reports playback has started within a session. - /// </summary> - /// <param name="playbackStartInfo">The playback start info.</param> - /// <response code="204">Playback start recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Playing")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo) - { - playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); - playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); - return NoContent(); - } + /// <summary> + /// Pings a playback session. + /// </summary> + /// <param name="playSessionId">Playback session id.</param> + /// <response code="204">Playback session pinged.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing/Ping")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId) + { + _transcodingJobHelper.PingTranscodingJob(playSessionId, null); + return NoContent(); + } - /// <summary> - /// Reports playback progress within a session. - /// </summary> - /// <param name="playbackProgressInfo">The playback progress info.</param> - /// <response code="204">Playback progress recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Playing/Progress")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo) + /// <summary> + /// Reports playback has stopped within a session. + /// </summary> + /// <param name="playbackStopInfo">The playback stop info.</param> + /// <response code="204">Playback stop recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Playing/Stopped")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo) + { + _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); + if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) { - playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); - playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); - return NoContent(); + await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); } - /// <summary> - /// Pings a playback session. - /// </summary> - /// <param name="playSessionId">Playback session id.</param> - /// <response code="204">Playback session pinged.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Playing/Ping")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult PingPlaybackSession([FromQuery, Required] string playSessionId) - { - _transcodingJobHelper.PingTranscodingJob(playSessionId, null); - return NoContent(); - } + playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Reports playback has stopped within a session. - /// </summary> - /// <param name="playbackStopInfo">The playback stop info.</param> - /// <response code="204">Playback stop recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Playing/Stopped")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo) + /// <summary> + /// Reports that a user has begun playing an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="mediaSourceId">The id of the MediaSource.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="playMethod">The play method.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="canSeek">Indicates if the client can seek.</param> + /// <response code="204">Play start recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/PlayingItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task<ActionResult> OnPlaybackStart( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId, + [FromQuery] string? mediaSourceId, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] PlayMethod? playMethod, + [FromQuery] string? liveStreamId, + [FromQuery] string? playSessionId, + [FromQuery] bool canSeek = false) + { + var playbackStartInfo = new PlaybackStartInfo { - _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); - if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) - { - await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); - } + CanSeek = canSeek, + ItemId = itemId, + MediaSourceId = mediaSourceId, + AudioStreamIndex = audioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex, + PlayMethod = playMethod ?? PlayMethod.Transcode, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId + }; - playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); - return NoContent(); - } + playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); + playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Reports that a user has begun playing an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <param name="mediaSourceId">The id of the MediaSource.</param> - /// <param name="audioStreamIndex">The audio stream index.</param> - /// <param name="subtitleStreamIndex">The subtitle stream index.</param> - /// <param name="playMethod">The play method.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="canSeek">Indicates if the client can seek.</param> - /// <response code="204">Play start recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Users/{userId}/PlayingItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] - public async Task<ActionResult> OnPlaybackStart( - [FromRoute, Required] Guid userId, - [FromRoute, Required] Guid itemId, - [FromQuery] string? mediaSourceId, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] PlayMethod? playMethod, - [FromQuery] string? liveStreamId, - [FromQuery] string? playSessionId, - [FromQuery] bool canSeek = false) + /// <summary> + /// Reports a user's playback progress. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="mediaSourceId">The id of the MediaSource.</param> + /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param> + /// <param name="audioStreamIndex">The audio stream index.</param> + /// <param name="subtitleStreamIndex">The subtitle stream index.</param> + /// <param name="volumeLevel">Scale of 0-100.</param> + /// <param name="playMethod">The play method.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="repeatMode">The repeat mode.</param> + /// <param name="isPaused">Indicates if the player is paused.</param> + /// <param name="isMuted">Indicates if the player is muted.</param> + /// <response code="204">Play progress recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task<ActionResult> OnPlaybackProgress( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId, + [FromQuery] string? mediaSourceId, + [FromQuery] long? positionTicks, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] int? volumeLevel, + [FromQuery] PlayMethod? playMethod, + [FromQuery] string? liveStreamId, + [FromQuery] string? playSessionId, + [FromQuery] RepeatMode? repeatMode, + [FromQuery] bool isPaused = false, + [FromQuery] bool isMuted = false) + { + var playbackProgressInfo = new PlaybackProgressInfo { - var playbackStartInfo = new PlaybackStartInfo - { - CanSeek = canSeek, - ItemId = itemId, - MediaSourceId = mediaSourceId, - AudioStreamIndex = audioStreamIndex, - SubtitleStreamIndex = subtitleStreamIndex, - PlayMethod = playMethod ?? PlayMethod.Transcode, - PlaySessionId = playSessionId, - LiveStreamId = liveStreamId - }; + ItemId = itemId, + PositionTicks = positionTicks, + IsMuted = isMuted, + IsPaused = isPaused, + MediaSourceId = mediaSourceId, + AudioStreamIndex = audioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex, + VolumeLevel = volumeLevel, + PlayMethod = playMethod ?? PlayMethod.Transcode, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + RepeatMode = repeatMode ?? RepeatMode.RepeatNone + }; - playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId); - playbackStartInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false); - return NoContent(); - } + playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); + playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Reports a user's playback progress. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <param name="mediaSourceId">The id of the MediaSource.</param> - /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param> - /// <param name="audioStreamIndex">The audio stream index.</param> - /// <param name="subtitleStreamIndex">The subtitle stream index.</param> - /// <param name="volumeLevel">Scale of 0-100.</param> - /// <param name="playMethod">The play method.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="repeatMode">The repeat mode.</param> - /// <param name="isPaused">Indicates if the player is paused.</param> - /// <param name="isMuted">Indicates if the player is muted.</param> - /// <response code="204">Play progress recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Users/{userId}/PlayingItems/{itemId}/Progress")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] - public async Task<ActionResult> OnPlaybackProgress( - [FromRoute, Required] Guid userId, - [FromRoute, Required] Guid itemId, - [FromQuery] string? mediaSourceId, - [FromQuery] long? positionTicks, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] int? volumeLevel, - [FromQuery] PlayMethod? playMethod, - [FromQuery] string? liveStreamId, - [FromQuery] string? playSessionId, - [FromQuery] RepeatMode? repeatMode, - [FromQuery] bool isPaused = false, - [FromQuery] bool isMuted = false) + /// <summary> + /// Reports that a user has stopped playing an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="mediaSourceId">The id of the MediaSource.</param> + /// <param name="nextMediaType">The next media type that will play.</param> + /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <response code="204">Playback stop recorded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Users/{userId}/PlayingItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] + public async Task<ActionResult> OnPlaybackStopped( + [FromRoute, Required] Guid userId, + [FromRoute, Required] Guid itemId, + [FromQuery] string? mediaSourceId, + [FromQuery] string? nextMediaType, + [FromQuery] long? positionTicks, + [FromQuery] string? liveStreamId, + [FromQuery] string? playSessionId) + { + var playbackStopInfo = new PlaybackStopInfo { - var playbackProgressInfo = new PlaybackProgressInfo - { - ItemId = itemId, - PositionTicks = positionTicks, - IsMuted = isMuted, - IsPaused = isPaused, - MediaSourceId = mediaSourceId, - AudioStreamIndex = audioStreamIndex, - SubtitleStreamIndex = subtitleStreamIndex, - VolumeLevel = volumeLevel, - PlayMethod = playMethod ?? PlayMethod.Transcode, - PlaySessionId = playSessionId, - LiveStreamId = liveStreamId, - RepeatMode = repeatMode ?? RepeatMode.RepeatNone - }; - - playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); - playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); - return NoContent(); - } + ItemId = itemId, + PositionTicks = positionTicks, + MediaSourceId = mediaSourceId, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + NextMediaType = nextMediaType + }; - /// <summary> - /// Reports that a user has stopped playing an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <param name="mediaSourceId">The id of the MediaSource.</param> - /// <param name="nextMediaType">The next media type that will play.</param> - /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="playSessionId">The play session id.</param> - /// <response code="204">Playback stop recorded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Users/{userId}/PlayingItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")] - public async Task<ActionResult> OnPlaybackStopped( - [FromRoute, Required] Guid userId, - [FromRoute, Required] Guid itemId, - [FromQuery] string? mediaSourceId, - [FromQuery] string? nextMediaType, - [FromQuery] long? positionTicks, - [FromQuery] string? liveStreamId, - [FromQuery] string? playSessionId) + _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); + if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) { - var playbackStopInfo = new PlaybackStopInfo - { - ItemId = itemId, - PositionTicks = positionTicks, - MediaSourceId = mediaSourceId, - PlaySessionId = playSessionId, - LiveStreamId = liveStreamId, - NextMediaType = nextMediaType - }; + await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); + } - _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty); - if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId)) - { - await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); - } + playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); + return NoContent(); + } - playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); - return NoContent(); + /// <summary> + /// Updates the played status. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <param name="wasPlayed">if set to <c>true</c> [was played].</param> + /// <param name="datePlayed">The date played.</param> + /// <returns>Task.</returns> + private UserItemDataDto UpdatePlayedStatus(User user, BaseItem item, bool wasPlayed, DateTime? datePlayed) + { + if (wasPlayed) + { + item.MarkPlayed(user, datePlayed, true); } - - /// <summary> - /// Updates the played status. - /// </summary> - /// <param name="user">The user.</param> - /// <param name="item">The item.</param> - /// <param name="wasPlayed">if set to <c>true</c> [was played].</param> - /// <param name="datePlayed">The date played.</param> - /// <returns>Task.</returns> - private UserItemDataDto UpdatePlayedStatus(User user, BaseItem item, bool wasPlayed, DateTime? datePlayed) + else { - if (wasPlayed) - { - item.MarkPlayed(user, datePlayed, true); - } - else - { - item.MarkUnplayed(user); - } - - return _userDataRepository.GetUserDataDto(item, user); + item.MarkUnplayed(user); } - private PlayMethod ValidatePlayMethod(PlayMethod method, string? playSessionId) + return _userDataRepository.GetUserDataDto(item, user); + } + + private PlayMethod ValidatePlayMethod(PlayMethod method, string? playSessionId) + { + if (method == PlayMethod.Transcode) { - if (method == PlayMethod.Transcode) + var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId); + if (job is null) { - var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId); - if (job is null) - { - return PlayMethod.DirectPlay; - } + return PlayMethod.DirectPlay; } - - return method; } + + return method; } } diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index b8a09990a..5a037d7a6 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.Json; @@ -17,250 +16,249 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Plugins controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class PluginsController : BaseJellyfinApiController { + private readonly IInstallationManager _installationManager; + private readonly IPluginManager _pluginManager; + private readonly JsonSerializerOptions _serializerOptions; + /// <summary> - /// Plugins controller. + /// Initializes a new instance of the <see cref="PluginsController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class PluginsController : BaseJellyfinApiController + /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> + /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param> + public PluginsController( + IInstallationManager installationManager, + IPluginManager pluginManager) { - private readonly IInstallationManager _installationManager; - private readonly IPluginManager _pluginManager; - private readonly JsonSerializerOptions _serializerOptions; + _installationManager = installationManager; + _pluginManager = pluginManager; + _serializerOptions = JsonDefaults.Options; + } - /// <summary> - /// Initializes a new instance of the <see cref="PluginsController"/> class. - /// </summary> - /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param> - /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param> - public PluginsController( - IInstallationManager installationManager, - IPluginManager pluginManager) + /// <summary> + /// Gets a list of currently installed plugins. + /// </summary> + /// <response code="200">Installed plugins returned.</response> + /// <returns>List of currently installed plugins.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<PluginInfo>> GetPlugins() + { + return Ok(_pluginManager.Plugins + .OrderBy(p => p.Name) + .Select(p => p.GetPluginInfo())); + } + + /// <summary> + /// Enables a disabled plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="204">Plugin enabled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/{version}/Enable")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + { + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin is null) { - _installationManager = installationManager; - _pluginManager = pluginManager; - _serializerOptions = JsonDefaults.Options; + return NotFound(); } - /// <summary> - /// Gets a list of currently installed plugins. - /// </summary> - /// <response code="200">Installed plugins returned.</response> - /// <returns>List of currently installed plugins.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<PluginInfo>> GetPlugins() + _pluginManager.EnablePlugin(plugin); + return NoContent(); + } + + /// <summary> + /// Disable a plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="204">Plugin disabled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/{version}/Disable")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + { + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin is null) { - return Ok(_pluginManager.Plugins - .OrderBy(p => p.Name) - .Select(p => p.GetPluginInfo())); + return NotFound(); } - /// <summary> - /// Enables a disabled plugin. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <param name="version">Plugin version.</param> - /// <response code="204">Plugin enabled.</response> - /// <response code="404">Plugin not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpPost("{pluginId}/{version}/Enable")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) - { - var plugin = _pluginManager.GetPlugin(pluginId, version); - if (plugin is null) - { - return NotFound(); - } + _pluginManager.DisablePlugin(plugin); + return NoContent(); + } - _pluginManager.EnablePlugin(plugin); - return NoContent(); + /// <summary> + /// Uninstalls a plugin by version. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="204">Plugin uninstalled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpDelete("{pluginId}/{version}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + { + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin is null) + { + return NotFound(); } - /// <summary> - /// Disable a plugin. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <param name="version">Plugin version.</param> - /// <response code="204">Plugin disabled.</response> - /// <response code="404">Plugin not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpPost("{pluginId}/{version}/Disable")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) - { - var plugin = _pluginManager.GetPlugin(pluginId, version); - if (plugin is null) - { - return NotFound(); - } + _installationManager.UninstallPlugin(plugin); + return NoContent(); + } - _pluginManager.DisablePlugin(plugin); - return NoContent(); - } + /// <summary> + /// Uninstalls a plugin. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="204">Plugin uninstalled.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpDelete("{pluginId}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use the UninstallPluginByVersion API.")] + public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId) + { + // If no version is given, return the current instance. + var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId)).ToList(); - /// <summary> - /// Uninstalls a plugin by version. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <param name="version">Plugin version.</param> - /// <response code="204">Plugin uninstalled.</response> - /// <response code="404">Plugin not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpDelete("{pluginId}/{version}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) - { - var plugin = _pluginManager.GetPlugin(pluginId, version); - if (plugin is null) - { - return NotFound(); - } + // Select the un-instanced one first. + var plugin = plugins.FirstOrDefault(p => p.Instance is null) ?? plugins.OrderBy(p => p.Manifest.Status).FirstOrDefault(); + if (plugin is not null) + { _installationManager.UninstallPlugin(plugin); return NoContent(); } - /// <summary> - /// Uninstalls a plugin. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <response code="204">Plugin uninstalled.</response> - /// <response code="404">Plugin not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpDelete("{pluginId}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("Please use the UninstallPluginByVersion API.")] - public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId) - { - // If no version is given, return the current instance. - var plugins = _pluginManager.Plugins.Where(p => p.Id.Equals(pluginId)).ToList(); - - // Select the un-instanced one first. - var plugin = plugins.FirstOrDefault(p => p.Instance is null) ?? plugins.OrderBy(p => p.Manifest.Status).FirstOrDefault(); - - if (plugin is not null) - { - _installationManager.UninstallPlugin(plugin); - return NoContent(); - } + return NotFound(); + } - return NotFound(); + /// <summary> + /// Gets plugin configuration. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="200">Plugin configuration returned.</response> + /// <response code="404">Plugin not found or plugin configuration not found.</response> + /// <returns>Plugin configuration.</returns> + [HttpGet("{pluginId}/Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId) + { + var plugin = _pluginManager.GetPlugin(pluginId); + if (plugin?.Instance is IHasPluginConfiguration configPlugin) + { + return configPlugin.Configuration; } - /// <summary> - /// Gets plugin configuration. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <response code="200">Plugin configuration returned.</response> - /// <response code="404">Plugin not found or plugin configuration not found.</response> - /// <returns>Plugin configuration.</returns> - [HttpGet("{pluginId}/Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId) - { - var plugin = _pluginManager.GetPlugin(pluginId); - if (plugin?.Instance is IHasPluginConfiguration configPlugin) - { - return configPlugin.Configuration; - } + return NotFound(); + } + /// <summary> + /// Updates plugin configuration. + /// </summary> + /// <remarks> + /// Accepts plugin configuration as JSON body. + /// </remarks> + /// <param name="pluginId">Plugin id.</param> + /// <response code="204">Plugin configuration updated.</response> + /// <response code="404">Plugin not found or plugin does not have configuration.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/Configuration")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId) + { + var plugin = _pluginManager.GetPlugin(pluginId); + if (plugin?.Instance is not IHasPluginConfiguration configPlugin) + { return NotFound(); } - /// <summary> - /// Updates plugin configuration. - /// </summary> - /// <remarks> - /// Accepts plugin configuration as JSON body. - /// </remarks> - /// <param name="pluginId">Plugin id.</param> - /// <response code="204">Plugin configuration updated.</response> - /// <response code="404">Plugin not found or plugin does not have configuration.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpPost("{pluginId}/Configuration")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId) - { - var plugin = _pluginManager.GetPlugin(pluginId); - if (plugin?.Instance is not IHasPluginConfiguration configPlugin) - { - return NotFound(); - } + var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions) + .ConfigureAwait(false); - var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, configPlugin.ConfigurationType, _serializerOptions) - .ConfigureAwait(false); + if (configuration is not null) + { + configPlugin.UpdateConfiguration(configuration); + } - if (configuration is not null) - { - configPlugin.UpdateConfiguration(configuration); - } + return NoContent(); + } - return NoContent(); + /// <summary> + /// Gets a plugin's image. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <param name="version">Plugin version.</param> + /// <response code="200">Plugin image returned.</response> + /// <returns>Plugin's image.</returns> + [HttpGet("{pluginId}/{version}/Image")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesImageFile] + [AllowAnonymous] + public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + { + var plugin = _pluginManager.GetPlugin(pluginId, version); + if (plugin is null) + { + return NotFound(); } - /// <summary> - /// Gets a plugin's image. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <param name="version">Plugin version.</param> - /// <response code="200">Plugin image returned.</response> - /// <returns>Plugin's image.</returns> - [HttpGet("{pluginId}/{version}/Image")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesImageFile] - [AllowAnonymous] - public ActionResult GetPluginImage([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) + var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty); + if (plugin.Manifest.ImagePath is null || !System.IO.File.Exists(imagePath)) { - var plugin = _pluginManager.GetPlugin(pluginId, version); - if (plugin is null) - { - return NotFound(); - } - - var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath ?? string.Empty); - if (plugin.Manifest.ImagePath is null || !System.IO.File.Exists(imagePath)) - { - return NotFound(); - } - - imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); - return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath)); + return NotFound(); } - /// <summary> - /// Gets a plugin's manifest. - /// </summary> - /// <param name="pluginId">Plugin id.</param> - /// <response code="204">Plugin manifest returned.</response> - /// <response code="404">Plugin not found.</response> - /// <returns>A <see cref="PluginManifest"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> - [HttpPost("{pluginId}/Manifest")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId) - { - var plugin = _pluginManager.GetPlugin(pluginId); + imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); + return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath)); + } - if (plugin is not null) - { - return plugin.Manifest; - } + /// <summary> + /// Gets a plugin's manifest. + /// </summary> + /// <param name="pluginId">Plugin id.</param> + /// <response code="204">Plugin manifest returned.</response> + /// <response code="404">Plugin not found.</response> + /// <returns>A <see cref="PluginManifest"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> + [HttpPost("{pluginId}/Manifest")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<PluginManifest> GetPluginManifest([FromRoute, Required] Guid pluginId) + { + var plugin = _pluginManager.GetPlugin(pluginId); - return NotFound(); + if (plugin is not null) + { + return plugin.Manifest; } + + return NotFound(); } } diff --git a/Jellyfin.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs index 6dbcdae22..a58e85b2b 100644 --- a/Jellyfin.Api/Controllers/QuickConnectController.cs +++ b/Jellyfin.Api/Controllers/QuickConnectController.cs @@ -3,7 +3,6 @@ using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; @@ -13,126 +12,125 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Quick connect controller. +/// </summary> +public class QuickConnectController : BaseJellyfinApiController { + private readonly IQuickConnect _quickConnect; + private readonly IAuthorizationContext _authContext; + + /// <summary> + /// Initializes a new instance of the <see cref="QuickConnectController"/> class. + /// </summary> + /// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext) + { + _quickConnect = quickConnect; + _authContext = authContext; + } + /// <summary> - /// Quick connect controller. + /// Gets the current quick connect state. /// </summary> - public class QuickConnectController : BaseJellyfinApiController + /// <response code="200">Quick connect state returned.</response> + /// <returns>Whether Quick Connect is enabled on the server or not.</returns> + [HttpGet("Enabled")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<bool> GetQuickConnectEnabled() { - private readonly IQuickConnect _quickConnect; - private readonly IAuthorizationContext _authContext; + return _quickConnect.IsEnabled; + } - /// <summary> - /// Initializes a new instance of the <see cref="QuickConnectController"/> class. - /// </summary> - /// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param> - /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> - public QuickConnectController(IQuickConnect quickConnect, IAuthorizationContext authContext) + /// <summary> + /// Initiate a new quick connect request. + /// </summary> + /// <response code="200">Quick connect request successfully created.</response> + /// <response code="401">Quick connect is not active on this server.</response> + /// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns> + [HttpPost("Initiate")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect() + { + try { - _quickConnect = quickConnect; - _authContext = authContext; + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + return _quickConnect.TryConnect(auth); } - - /// <summary> - /// Gets the current quick connect state. - /// </summary> - /// <response code="200">Quick connect state returned.</response> - /// <returns>Whether Quick Connect is enabled on the server or not.</returns> - [HttpGet("Enabled")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<bool> GetQuickConnectEnabled() + catch (AuthenticationException) { - return _quickConnect.IsEnabled; + return Unauthorized("Quick connect is disabled"); } + } + + /// <summary> + /// Old version of <see cref="InitiateQuickConnect" /> using a GET method. + /// Still available to avoid breaking compatibility. + /// </summary> + /// <returns>The result of <see cref="InitiateQuickConnect" />.</returns> + [Obsolete("Use POST request instead")] + [HttpGet("Initiate")] + [ApiExplorerSettings(IgnoreApi = true)] + public Task<ActionResult<QuickConnectResult>> InitiateQuickConnectLegacy() => InitiateQuickConnect(); - /// <summary> - /// Initiate a new quick connect request. - /// </summary> - /// <response code="200">Quick connect request successfully created.</response> - /// <response code="401">Quick connect is not active on this server.</response> - /// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns> - [HttpPost("Initiate")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect() + /// <summary> + /// Attempts to retrieve authentication information. + /// </summary> + /// <param name="secret">Secret previously returned from the Initiate endpoint.</param> + /// <response code="200">Quick connect result returned.</response> + /// <response code="404">Unknown quick connect secret.</response> + /// <returns>An updated <see cref="QuickConnectResult"/>.</returns> + [HttpGet("Connect")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QuickConnectResult> GetQuickConnectState([FromQuery, Required] string secret) + { + try + { + return _quickConnect.CheckRequestStatus(secret); + } + catch (ResourceNotFoundException) + { + return NotFound("Unknown secret"); + } + catch (AuthenticationException) { - try - { - var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); - return _quickConnect.TryConnect(auth); - } - catch (AuthenticationException) - { - return Unauthorized("Quick connect is disabled"); - } + return Unauthorized("Quick connect is disabled"); } + } - /// <summary> - /// Old version of <see cref="InitiateQuickConnect" /> using a GET method. - /// Still available to avoid breaking compatibility. - /// </summary> - /// <returns>The result of <see cref="InitiateQuickConnect" />.</returns> - [Obsolete("Use POST request instead")] - [HttpGet("Initiate")] - [ApiExplorerSettings(IgnoreApi = true)] - public Task<ActionResult<QuickConnectResult>> InitiateQuickConnectLegacy() => InitiateQuickConnect(); + /// <summary> + /// Authorizes a pending quick connect request. + /// </summary> + /// <param name="code">Quick connect code to authorize.</param> + /// <param name="userId">The user the authorize. Access to the requested user is required.</param> + /// <response code="200">Quick connect result authorized successfully.</response> + /// <response code="403">Unknown user id.</response> + /// <returns>Boolean indicating if the authorization was successful.</returns> + [HttpPost("Authorize")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null) + { + var currentUserId = User.GetUserId(); + var actualUserId = userId ?? currentUserId; - /// <summary> - /// Attempts to retrieve authentication information. - /// </summary> - /// <param name="secret">Secret previously returned from the Initiate endpoint.</param> - /// <response code="200">Quick connect result returned.</response> - /// <response code="404">Unknown quick connect secret.</response> - /// <returns>An updated <see cref="QuickConnectResult"/>.</returns> - [HttpGet("Connect")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<QuickConnectResult> GetQuickConnectState([FromQuery, Required] string secret) + if (actualUserId.Equals(default) || (!userId.Equals(currentUserId) && !User.IsInRole(UserRoles.Administrator))) { - try - { - return _quickConnect.CheckRequestStatus(secret); - } - catch (ResourceNotFoundException) - { - return NotFound("Unknown secret"); - } - catch (AuthenticationException) - { - return Unauthorized("Quick connect is disabled"); - } + return Forbid("Unknown user id"); } - /// <summary> - /// Authorizes a pending quick connect request. - /// </summary> - /// <param name="code">Quick connect code to authorize.</param> - /// <param name="userId">The user the authorize. Access to the requested user is required.</param> - /// <response code="200">Quick connect result authorized successfully.</response> - /// <response code="403">Unknown user id.</response> - /// <returns>Boolean indicating if the authorization was successful.</returns> - [HttpPost("Authorize")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null) + try { - var currentUserId = User.GetUserId(); - var actualUserId = userId ?? currentUserId; - - if (actualUserId.Equals(default) || (!userId.Equals(currentUserId) && !User.IsInRole(UserRoles.Administrator))) - { - return Forbid("Unknown user id"); - } - - try - { - return await _quickConnect.AuthorizeRequest(actualUserId, code).ConfigureAwait(false); - } - catch (AuthenticationException) - { - return Unauthorized("Quick connect is disabled"); - } + return await _quickConnect.AuthorizeRequest(actualUserId, code).ConfigureAwait(false); + } + catch (AuthenticationException) + { + return Unauthorized("Quick connect is disabled"); } } } diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index da9e8cf90..445c5594f 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -15,165 +15,164 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Remote Images Controller. +/// </summary> +[Route("")] +public class RemoteImageController : BaseJellyfinApiController { + private readonly IProviderManager _providerManager; + private readonly IServerApplicationPaths _applicationPaths; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="RemoteImageController"/> class. + /// </summary> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public RemoteImageController( + IProviderManager providerManager, + IServerApplicationPaths applicationPaths, + ILibraryManager libraryManager) + { + _providerManager = providerManager; + _applicationPaths = applicationPaths; + _libraryManager = libraryManager; + } + /// <summary> - /// Remote Images Controller. + /// Gets available remote images for an item. /// </summary> - [Route("")] - public class RemoteImageController : BaseJellyfinApiController + /// <param name="itemId">Item Id.</param> + /// <param name="type">The image type.</param> + /// <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="providerName">Optional. The image provider to use.</param> + /// <param name="includeAllLanguages">Optional. Include all languages.</param> + /// <response code="200">Remote Images returned.</response> + /// <response code="404">Item not found.</response> + /// <returns>Remote Image Result.</returns> + [HttpGet("Items/{itemId}/RemoteImages")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<RemoteImageResult>> GetRemoteImages( + [FromRoute, Required] Guid itemId, + [FromQuery] ImageType? type, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? providerName, + [FromQuery] bool includeAllLanguages = false) { - private readonly IProviderManager _providerManager; - private readonly IServerApplicationPaths _applicationPaths; - private readonly ILibraryManager _libraryManager; - - /// <summary> - /// Initializes a new instance of the <see cref="RemoteImageController"/> class. - /// </summary> - /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> - /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public RemoteImageController( - IProviderManager providerManager, - IServerApplicationPaths applicationPaths, - ILibraryManager libraryManager) + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - _providerManager = providerManager; - _applicationPaths = applicationPaths; - _libraryManager = libraryManager; + return NotFound(); } - /// <summary> - /// Gets available remote images for an item. - /// </summary> - /// <param name="itemId">Item Id.</param> - /// <param name="type">The image type.</param> - /// <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="providerName">Optional. The image provider to use.</param> - /// <param name="includeAllLanguages">Optional. Include all languages.</param> - /// <response code="200">Remote Images returned.</response> - /// <response code="404">Item not found.</response> - /// <returns>Remote Image Result.</returns> - [HttpGet("Items/{itemId}/RemoteImages")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult<RemoteImageResult>> GetRemoteImages( - [FromRoute, Required] Guid itemId, - [FromQuery] ImageType? type, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? providerName, - [FromQuery] bool includeAllLanguages = false) + var images = await _providerManager.GetAvailableRemoteImages( + item, + new RemoteImageQuery(providerName ?? string.Empty) + { + IncludeAllLanguages = includeAllLanguages, + IncludeDisabledProviders = true, + ImageType = type + }, + CancellationToken.None) + .ConfigureAwait(false); + + var imageArray = images.ToArray(); + var allProviders = _providerManager.GetRemoteImageProviderInfo(item); + if (type.HasValue) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } - - var images = await _providerManager.GetAvailableRemoteImages( - item, - new RemoteImageQuery(providerName ?? string.Empty) - { - IncludeAllLanguages = includeAllLanguages, - IncludeDisabledProviders = true, - ImageType = type - }, - CancellationToken.None) - .ConfigureAwait(false); - - var imageArray = images.ToArray(); - var allProviders = _providerManager.GetRemoteImageProviderInfo(item); - if (type.HasValue) - { - allProviders = allProviders.Where(o => o.SupportedImages.Contains(type.Value)); - } - - var result = new RemoteImageResult - { - TotalRecordCount = imageArray.Length, - Providers = allProviders.Select(o => o.Name) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray() - }; - - if (startIndex.HasValue) - { - imageArray = imageArray.Skip(startIndex.Value).ToArray(); - } - - if (limit.HasValue) - { - imageArray = imageArray.Take(limit.Value).ToArray(); - } - - result.Images = imageArray; - return result; + allProviders = allProviders.Where(o => o.SupportedImages.Contains(type.Value)); } - /// <summary> - /// Gets available remote image providers for an item. - /// </summary> - /// <param name="itemId">Item Id.</param> - /// <response code="200">Returned remote image providers.</response> - /// <response code="404">Item not found.</response> - /// <returns>List of remote image providers.</returns> - [HttpGet("Items/{itemId}/RemoteImages/Providers")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId) + var result = new RemoteImageResult { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + TotalRecordCount = imageArray.Length, + Providers = allProviders.Select(o => o.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + }; - return Ok(_providerManager.GetRemoteImageProviderInfo(item)); + if (startIndex.HasValue) + { + imageArray = imageArray.Skip(startIndex.Value).ToArray(); } - /// <summary> - /// Downloads a remote image for an item. - /// </summary> - /// <param name="itemId">Item Id.</param> - /// <param name="type">The image type.</param> - /// <param name="imageUrl">The image url.</param> - /// <response code="204">Remote image downloaded.</response> - /// <response code="404">Remote image not found.</response> - /// <returns>Download status.</returns> - [HttpPost("Items/{itemId}/RemoteImages/Download")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DownloadRemoteImage( - [FromRoute, Required] Guid itemId, - [FromQuery, Required] ImageType type, - [FromQuery] string? imageUrl) + if (limit.HasValue) { - var item = _libraryManager.GetItemById(itemId); - if (item is null) - { - return NotFound(); - } + imageArray = imageArray.Take(limit.Value).ToArray(); + } - await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None) - .ConfigureAwait(false); + result.Images = imageArray; + return result; + } - await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - return NoContent(); + /// <summary> + /// Gets available remote image providers for an item. + /// </summary> + /// <param name="itemId">Item Id.</param> + /// <response code="200">Returned remote image providers.</response> + /// <response code="404">Item not found.</response> + /// <returns>List of remote image providers.</returns> + [HttpGet("Items/{itemId}/RemoteImages/Providers")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) + { + return NotFound(); } - /// <summary> - /// Gets the full cache path. - /// </summary> - /// <param name="filename">The filename.</param> - /// <returns>System.String.</returns> - private string GetFullCachePath(string filename) + return Ok(_providerManager.GetRemoteImageProviderInfo(item)); + } + + /// <summary> + /// Downloads a remote image for an item. + /// </summary> + /// <param name="itemId">Item Id.</param> + /// <param name="type">The image type.</param> + /// <param name="imageUrl">The image url.</param> + /// <response code="204">Remote image downloaded.</response> + /// <response code="404">Remote image not found.</response> + /// <returns>Download status.</returns> + [HttpPost("Items/{itemId}/RemoteImages/Download")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DownloadRemoteImage( + [FromRoute, Required] Guid itemId, + [FromQuery, Required] ImageType type, + [FromQuery] string? imageUrl) + { + var item = _libraryManager.GetItemById(itemId); + if (item is null) { - return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); + return NotFound(); } + + await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None) + .ConfigureAwait(false); + + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Gets the full cache path. + /// </summary> + /// <param name="filename">The filename.</param> + /// <returns>System.String.</returns> + private string GetFullCachePath(string filename) + { + return Path.Combine(_applicationPaths.CachePath, "remote-images", filename.Substring(0, 1), filename); } } diff --git a/Jellyfin.Api/Controllers/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs index 832e14505..c8fa11ac6 100644 --- a/Jellyfin.Api/Controllers/ScheduledTasksController.cs +++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs @@ -8,154 +8,153 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Scheduled Tasks Controller. +/// </summary> +[Authorize(Policy = Policies.RequiresElevation)] +public class ScheduledTasksController : BaseJellyfinApiController { + private readonly ITaskManager _taskManager; + /// <summary> - /// Scheduled Tasks Controller. + /// Initializes a new instance of the <see cref="ScheduledTasksController"/> class. /// </summary> - [Authorize(Policy = Policies.RequiresElevation)] - public class ScheduledTasksController : BaseJellyfinApiController + /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param> + public ScheduledTasksController(ITaskManager taskManager) { - private readonly ITaskManager _taskManager; + _taskManager = taskManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="ScheduledTasksController"/> class. - /// </summary> - /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param> - public ScheduledTasksController(ITaskManager taskManager) - { - _taskManager = taskManager; - } + /// <summary> + /// Get tasks. + /// </summary> + /// <param name="isHidden">Optional filter tasks that are hidden, or not.</param> + /// <param name="isEnabled">Optional filter tasks that are enabled, or not.</param> + /// <response code="200">Scheduled tasks retrieved.</response> + /// <returns>The list of scheduled tasks.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<TaskInfo> GetTasks( + [FromQuery] bool? isHidden, + [FromQuery] bool? isEnabled) + { + IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name); - /// <summary> - /// Get tasks. - /// </summary> - /// <param name="isHidden">Optional filter tasks that are hidden, or not.</param> - /// <param name="isEnabled">Optional filter tasks that are enabled, or not.</param> - /// <response code="200">Scheduled tasks retrieved.</response> - /// <returns>The list of scheduled tasks.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<TaskInfo> GetTasks( - [FromQuery] bool? isHidden, - [FromQuery] bool? isEnabled) + foreach (var task in tasks) { - IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name); - - foreach (var task in tasks) + if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask) { - if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask) + if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden) { - if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden) - { - continue; - } - - if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled) - { - continue; - } + continue; } - yield return ScheduledTaskHelpers.GetTaskInfo(task); + if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled) + { + continue; + } } - } - /// <summary> - /// Get task by id. - /// </summary> - /// <param name="taskId">Task Id.</param> - /// <response code="200">Task retrieved.</response> - /// <response code="404">Task not found.</response> - /// <returns>An <see cref="OkResult"/> containing the task on success, or a <see cref="NotFoundResult"/> if the task could not be found.</returns> - [HttpGet("{taskId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<TaskInfo> GetTask([FromRoute, Required] string taskId) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(i => - string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase)); + yield return ScheduledTaskHelpers.GetTaskInfo(task); + } + } - if (task is null) - { - return NotFound(); - } + /// <summary> + /// Get task by id. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <response code="200">Task retrieved.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="OkResult"/> containing the task on success, or a <see cref="NotFoundResult"/> if the task could not be found.</returns> + [HttpGet("{taskId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<TaskInfo> GetTask([FromRoute, Required] string taskId) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(i => + string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase)); - return ScheduledTaskHelpers.GetTaskInfo(task); + if (task is null) + { + return NotFound(); } - /// <summary> - /// Start specified task. - /// </summary> - /// <param name="taskId">Task Id.</param> - /// <response code="204">Task started.</response> - /// <response code="404">Task not found.</response> - /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> - [HttpPost("Running/{taskId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult StartTask([FromRoute, Required] string taskId) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(o => - o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); + return ScheduledTaskHelpers.GetTaskInfo(task); + } - if (task is null) - { - return NotFound(); - } + /// <summary> + /// Start specified task. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <response code="204">Task started.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpPost("Running/{taskId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult StartTask([FromRoute, Required] string taskId) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(o => + o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); - _taskManager.Execute(task, new TaskOptions()); - return NoContent(); + if (task is null) + { + return NotFound(); } - /// <summary> - /// Stop specified task. - /// </summary> - /// <param name="taskId">Task Id.</param> - /// <response code="204">Task stopped.</response> - /// <response code="404">Task not found.</response> - /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> - [HttpDelete("Running/{taskId}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult StopTask([FromRoute, Required] string taskId) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(o => - o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); + _taskManager.Execute(task, new TaskOptions()); + return NoContent(); + } - if (task is null) - { - return NotFound(); - } + /// <summary> + /// Stop specified task. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <response code="204">Task stopped.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpDelete("Running/{taskId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult StopTask([FromRoute, Required] string taskId) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(o => + o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); - _taskManager.Cancel(task); - return NoContent(); + if (task is null) + { + return NotFound(); } - /// <summary> - /// Update specified task triggers. - /// </summary> - /// <param name="taskId">Task Id.</param> - /// <param name="triggerInfos">Triggers.</param> - /// <response code="204">Task triggers updated.</response> - /// <response code="404">Task not found.</response> - /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> - [HttpPost("{taskId}/Triggers")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult UpdateTask( - [FromRoute, Required] string taskId, - [FromBody, Required] TaskTriggerInfo[] triggerInfos) - { - var task = _taskManager.ScheduledTasks.FirstOrDefault(o => - o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); - if (task is null) - { - return NotFound(); - } + _taskManager.Cancel(task); + return NoContent(); + } - task.Triggers = triggerInfos; - return NoContent(); + /// <summary> + /// Update specified task triggers. + /// </summary> + /// <param name="taskId">Task Id.</param> + /// <param name="triggerInfos">Triggers.</param> + /// <response code="204">Task triggers updated.</response> + /// <response code="404">Task not found.</response> + /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns> + [HttpPost("{taskId}/Triggers")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateTask( + [FromRoute, Required] string taskId, + [FromBody, Required] TaskTriggerInfo[] triggerInfos) + { + var task = _taskManager.ScheduledTasks.FirstOrDefault(o => + o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase)); + if (task is null) + { + return NotFound(); } + + task.Triggers = triggerInfos; + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 3b7719f37..46b4920ca 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -20,247 +20,246 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Search controller. +/// </summary> +[Route("Search/Hints")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class SearchController : BaseJellyfinApiController { + private readonly ISearchEngine _searchEngine; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IImageProcessor _imageProcessor; + /// <summary> - /// Search controller. + /// Initializes a new instance of the <see cref="SearchController"/> class. /// </summary> - [Route("Search/Hints")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class SearchController : BaseJellyfinApiController + /// <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) { - private readonly ISearchEngine _searchEngine; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IImageProcessor _imageProcessor; + _searchEngine = searchEngine; + _libraryManager = libraryManager; + _dtoService = dtoService; + _imageProcessor = 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) + /// <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 delimited.</param> + /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimited.</param> + /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimited.</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> GetSearchHints( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] Guid? userId, + [FromQuery, Required] string searchTerm, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery] Guid? 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 { - _searchEngine = searchEngine; - _libraryManager = libraryManager; - _dtoService = dtoService; - _imageProcessor = imageProcessor; - } + Limit = limit, + SearchTerm = searchTerm, + IncludeArtists = includeArtists, + IncludeGenres = includeGenres, + IncludeMedia = includeMedia, + IncludePeople = includePeople, + IncludeStudios = includeStudios, + StartIndex = startIndex, + UserId = userId ?? Guid.Empty, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + MediaTypes = mediaTypes, + ParentId = parentId, - /// <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 delimited.</param> - /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimited.</param> - /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimited.</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> GetSearchHints( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] Guid? userId, - [FromQuery, Required] string searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery] Guid? 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 ?? Guid.Empty, - IncludeItemTypes = includeItemTypes, - ExcludeItemTypes = excludeItemTypes, - MediaTypes = mediaTypes, - ParentId = parentId, + IsKids = isKids, + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsSports = isSports + }); - IsKids = isKids, - IsMovie = isMovie, - IsNews = isNews, - IsSeries = isSeries, - IsSports = isSports - }); + return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount); + } - return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount); - } + /// <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; - /// <summary> - /// Gets the search hint result. - /// </summary> - /// <param name="hintInfo">The hint info.</param> - /// <returns>SearchHintResult.</returns> - private SearchHint GetSearchHintResult(SearchHintInfo hintInfo) + var result = new SearchHint { - var item = hintInfo.Item; - - var result = new SearchHint - { - Name = item.Name, - IndexNumber = item.IndexNumber, - ParentIndexNumber = item.ParentIndexNumber, - Id = item.Id, - Type = item.GetBaseItemKind(), - MediaType = item.MediaType, - MatchedTerm = hintInfo.MatchedTerm, - RunTimeTicks = item.RunTimeTicks, - ProductionYear = item.ProductionYear, - ChannelId = item.ChannelId, - EndDate = item.EndDate - }; + Name = item.Name, + IndexNumber = item.IndexNumber, + ParentIndexNumber = item.ParentIndexNumber, + Id = item.Id, + Type = item.GetBaseItemKind(), + MediaType = item.MediaType, + MatchedTerm = hintInfo.MatchedTerm, + RunTimeTicks = item.RunTimeTicks, + ProductionYear = item.ProductionYear, + ChannelId = item.ChannelId, + EndDate = item.EndDate + }; #pragma warning disable CS0618 - // Kept for compatibility with older clients - result.ItemId = result.Id; + // Kept for compatibility with older clients + result.ItemId = result.Id; #pragma warning restore CS0618 - if (item.IsFolder) - { - result.IsFolder = true; - } - - var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary); + if (item.IsFolder) + { + result.IsFolder = true; + } - if (primaryImageTag is not null) - { - result.PrimaryImageTag = primaryImageTag; - result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item); - } + var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary); - SetThumbImageInfo(result, item); - SetBackdropImageInfo(result, item); + if (primaryImageTag is not null) + { + result.PrimaryImageTag = primaryImageTag; + result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(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(); - } + SetThumbImageInfo(result, item); + SetBackdropImageInfo(result, item); - break; - case MusicAlbum album: - result.Artists = album.Artists; - result.AlbumArtist = album.AlbumArtist; - break; - case Audio song: - result.AlbumArtist = song.AlbumArtists?.FirstOrDefault(); - result.Artists = song.Artists; + 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(); + } - MusicAlbum musicAlbum = song.AlbumEntity; + break; + case MusicAlbum album: + result.Artists = album.Artists; + result.AlbumArtist = album.AlbumArtist; + break; + case Audio song: + result.AlbumArtist = song.AlbumArtists?.FirstOrDefault(); + result.Artists = song.Artists; - if (musicAlbum is not null) - { - result.Album = musicAlbum.Name; - result.AlbumId = musicAlbum.Id; - } - else - { - result.Album = song.Album; - } + MusicAlbum musicAlbum = song.AlbumEntity; - break; - } + if (musicAlbum is not null) + { + result.Album = musicAlbum.Name; + result.AlbumId = musicAlbum.Id; + } + else + { + result.Album = song.Album; + } - if (!item.ChannelId.Equals(default)) - { - var channel = _libraryManager.GetItemById(item.ChannelId); - result.ChannelName = channel?.Name; - } + break; + } - return result; + if (!item.ChannelId.Equals(default)) + { + var channel = _libraryManager.GetItemById(item.ChannelId); + result.ChannelName = channel?.Name; } - private void SetThumbImageInfo(SearchHint hint, BaseItem item) + return result; + } + + private void SetThumbImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; + + if (itemWithImage is null && item is Episode) { - var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; + itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb); + } - if (itemWithImage is null && item is Episode) - { - itemWithImage = GetParentWithImage<Series>(item, ImageType.Thumb); - } + itemWithImage ??= GetParentWithImage<BaseItem>(item, ImageType.Thumb); - itemWithImage ??= GetParentWithImage<BaseItem>(item, ImageType.Thumb); + if (itemWithImage is not null) + { + var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb); - if (itemWithImage is not null) + if (tag is not null) { - var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb); - - if (tag is not null) - { - hint.ThumbImageTag = tag; - hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); - } + 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); - private void SetBackdropImageInfo(SearchHint hint, BaseItem item) + if (itemWithImage is not null) { - var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null) - ?? GetParentWithImage<BaseItem>(item, ImageType.Backdrop); + var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop); - if (itemWithImage is not null) + if (tag is not null) { - var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop); - - if (tag is not null) - { - hint.BackdropImageTag = tag; - hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); - } + 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)); - } + 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/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 25f930135..ef3364478 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -19,480 +19,479 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The session controller. +/// </summary> +[Route("")] +public class SessionController : BaseJellyfinApiController { + private readonly ISessionManager _sessionManager; + private readonly IUserManager _userManager; + private readonly IDeviceManager _deviceManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SessionController"/> class. + /// </summary> + /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> + /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> + /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> + public SessionController( + ISessionManager sessionManager, + IUserManager userManager, + IDeviceManager deviceManager) + { + _sessionManager = sessionManager; + _userManager = userManager; + _deviceManager = deviceManager; + } + /// <summary> - /// The session controller. + /// Gets a list of sessions. /// </summary> - [Route("")] - public class SessionController : BaseJellyfinApiController + /// <param name="controllableByUserId">Filter by sessions that a given user is allowed to remote control.</param> + /// <param name="deviceId">Filter by device Id.</param> + /// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param> + /// <response code="200">List of sessions returned.</response> + /// <returns>An <see cref="IEnumerable{SessionInfo}"/> with the available sessions.</returns> + [HttpGet("Sessions")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<SessionInfo>> GetSessions( + [FromQuery] Guid? controllableByUserId, + [FromQuery] string? deviceId, + [FromQuery] int? activeWithinSeconds) { - private readonly ISessionManager _sessionManager; - private readonly IUserManager _userManager; - private readonly IDeviceManager _deviceManager; - - /// <summary> - /// Initializes a new instance of the <see cref="SessionController"/> class. - /// </summary> - /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param> - /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param> - /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param> - public SessionController( - ISessionManager sessionManager, - IUserManager userManager, - IDeviceManager deviceManager) + var result = _sessionManager.Sessions; + + if (!string.IsNullOrEmpty(deviceId)) { - _sessionManager = sessionManager; - _userManager = userManager; - _deviceManager = deviceManager; + result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); } - /// <summary> - /// Gets a list of sessions. - /// </summary> - /// <param name="controllableByUserId">Filter by sessions that a given user is allowed to remote control.</param> - /// <param name="deviceId">Filter by device Id.</param> - /// <param name="activeWithinSeconds">Optional. Filter by sessions that were active in the last n seconds.</param> - /// <response code="200">List of sessions returned.</response> - /// <returns>An <see cref="IEnumerable{SessionInfo}"/> with the available sessions.</returns> - [HttpGet("Sessions")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<SessionInfo>> GetSessions( - [FromQuery] Guid? controllableByUserId, - [FromQuery] string? deviceId, - [FromQuery] int? activeWithinSeconds) + if (controllableByUserId.HasValue && !controllableByUserId.Equals(default)) { - var result = _sessionManager.Sessions; + result = result.Where(i => i.SupportsRemoteControl); - if (!string.IsNullOrEmpty(deviceId)) + var user = _userManager.GetUserById(controllableByUserId.Value); + + if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) { - result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); + result = result.Where(i => i.UserId.Equals(default) || i.ContainsUser(controllableByUserId.Value)); } - if (controllableByUserId.HasValue && !controllableByUserId.Equals(default)) + if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) { - result = result.Where(i => i.SupportsRemoteControl); - - var user = _userManager.GetUserById(controllableByUserId.Value); - - if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) - { - result = result.Where(i => i.UserId.Equals(default) || i.ContainsUser(controllableByUserId.Value)); - } - - if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) - { - result = result.Where(i => !i.UserId.Equals(default)); - } + result = result.Where(i => !i.UserId.Equals(default)); + } - if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0) - { - var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value); - result = result.Where(i => i.LastActivityDate >= minActiveDate); - } + if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0) + { + var minActiveDate = DateTime.UtcNow.AddSeconds(0 - activeWithinSeconds.Value); + result = result.Where(i => i.LastActivityDate >= minActiveDate); + } - result = result.Where(i => + result = result.Where(i => + { + if (!string.IsNullOrWhiteSpace(i.DeviceId)) { - if (!string.IsNullOrWhiteSpace(i.DeviceId)) + if (!_deviceManager.CanAccessDevice(user, i.DeviceId)) { - if (!_deviceManager.CanAccessDevice(user, i.DeviceId)) - { - return false; - } + return false; } + } - return true; - }); - } - - return Ok(result); + return true; + }); } - /// <summary> - /// Instructs a session to browse to an item or view. - /// </summary> - /// <param name="sessionId">The session Id.</param> - /// <param name="itemType">The type of item to browse to.</param> - /// <param name="itemId">The Id of the item.</param> - /// <param name="itemName">The name of the item.</param> - /// <response code="204">Instruction sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Viewing")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> DisplayContent( - [FromRoute, Required] string sessionId, - [FromQuery, Required] BaseItemKind itemType, - [FromQuery, Required] string itemId, - [FromQuery, Required] string itemName) + return Ok(result); + } + + /// <summary> + /// Instructs a session to browse to an item or view. + /// </summary> + /// <param name="sessionId">The session Id.</param> + /// <param name="itemType">The type of item to browse to.</param> + /// <param name="itemId">The Id of the item.</param> + /// <param name="itemName">The name of the item.</param> + /// <response code="204">Instruction sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Viewing")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> DisplayContent( + [FromRoute, Required] string sessionId, + [FromQuery, Required] BaseItemKind itemType, + [FromQuery, Required] string itemId, + [FromQuery, Required] string itemName) + { + var command = new BrowseRequest { - var command = new BrowseRequest - { - ItemId = itemId, - ItemName = itemName, - ItemType = itemType - }; - - await _sessionManager.SendBrowseCommand( - await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), - sessionId, - command, - CancellationToken.None) - .ConfigureAwait(false); - - return NoContent(); - } + ItemId = itemId, + ItemName = itemName, + ItemType = itemType + }; + + await _sessionManager.SendBrowseCommand( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + command, + CancellationToken.None) + .ConfigureAwait(false); + + return NoContent(); + } - /// <summary> - /// Instructs a session to play an item. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param> - /// <param name="itemIds">The ids of the items to play, comma delimited.</param> - /// <param name="startPositionTicks">The starting position of the first item.</param> - /// <param name="mediaSourceId">Optional. The media source id.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to play.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to play.</param> - /// <param name="startIndex">Optional. The start index.</param> - /// <response code="204">Instruction sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Playing")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> Play( - [FromRoute, Required] string sessionId, - [FromQuery, Required] PlayCommand playCommand, - [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds, - [FromQuery] long? startPositionTicks, - [FromQuery] string? mediaSourceId, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] int? startIndex) + /// <summary> + /// Instructs a session to play an item. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param> + /// <param name="itemIds">The ids of the items to play, comma delimited.</param> + /// <param name="startPositionTicks">The starting position of the first item.</param> + /// <param name="mediaSourceId">Optional. The media source id.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to play.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to play.</param> + /// <param name="startIndex">Optional. The start index.</param> + /// <response code="204">Instruction sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Playing")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> Play( + [FromRoute, Required] string sessionId, + [FromQuery, Required] PlayCommand playCommand, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds, + [FromQuery] long? startPositionTicks, + [FromQuery] string? mediaSourceId, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] int? startIndex) + { + var playRequest = new PlayRequest { - var playRequest = new PlayRequest + ItemIds = itemIds, + StartPositionTicks = startPositionTicks, + PlayCommand = playCommand, + MediaSourceId = mediaSourceId, + AudioStreamIndex = audioStreamIndex, + SubtitleStreamIndex = subtitleStreamIndex, + StartIndex = startIndex + }; + + await _sessionManager.SendPlayCommand( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + playRequest, + CancellationToken.None) + .ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Issues a playstate command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The <see cref="PlaystateCommand"/>.</param> + /// <param name="seekPositionTicks">The optional position ticks.</param> + /// <param name="controllingUserId">The optional controlling user id.</param> + /// <response code="204">Playstate command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Playing/{command}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SendPlaystateCommand( + [FromRoute, Required] string sessionId, + [FromRoute, Required] PlaystateCommand command, + [FromQuery] long? seekPositionTicks, + [FromQuery] string? controllingUserId) + { + await _sessionManager.SendPlaystateCommand( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + new PlaystateRequest() { - ItemIds = itemIds, - StartPositionTicks = startPositionTicks, - PlayCommand = playCommand, - MediaSourceId = mediaSourceId, - AudioStreamIndex = audioStreamIndex, - SubtitleStreamIndex = subtitleStreamIndex, - StartIndex = startIndex - }; - - await _sessionManager.SendPlayCommand( - await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), - sessionId, - playRequest, - CancellationToken.None) - .ConfigureAwait(false); - - return NoContent(); - } + Command = command, + ControllingUserId = controllingUserId, + SeekPositionTicks = seekPositionTicks, + }, + CancellationToken.None) + .ConfigureAwait(false); + + return NoContent(); + } - /// <summary> - /// Issues a playstate command to a client. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="command">The <see cref="PlaystateCommand"/>.</param> - /// <param name="seekPositionTicks">The optional position ticks.</param> - /// <param name="controllingUserId">The optional controlling user id.</param> - /// <response code="204">Playstate command sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Playing/{command}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SendPlaystateCommand( - [FromRoute, Required] string sessionId, - [FromRoute, Required] PlaystateCommand command, - [FromQuery] long? seekPositionTicks, - [FromQuery] string? controllingUserId) + /// <summary> + /// Issues a system command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The command to send.</param> + /// <response code="204">System command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/System/{command}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SendSystemCommand( + [FromRoute, Required] string sessionId, + [FromRoute, Required] GeneralCommandType command) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var generalCommand = new GeneralCommand { - await _sessionManager.SendPlaystateCommand( - await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), - sessionId, - new PlaystateRequest() - { - Command = command, - ControllingUserId = controllingUserId, - SeekPositionTicks = seekPositionTicks, - }, - CancellationToken.None) - .ConfigureAwait(false); - - return NoContent(); - } + Name = command, + ControllingUserId = currentSession.UserId + }; - /// <summary> - /// Issues a system command to a client. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="command">The command to send.</param> - /// <response code="204">System command sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/System/{command}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SendSystemCommand( - [FromRoute, Required] string sessionId, - [FromRoute, Required] GeneralCommandType command) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var generalCommand = new GeneralCommand - { - Name = command, - ControllingUserId = currentSession.UserId - }; + await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None).ConfigureAwait(false); - await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } - return NoContent(); - } + /// <summary> + /// Issues a general command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The command to send.</param> + /// <response code="204">General command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Command/{command}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SendGeneralCommand( + [FromRoute, Required] string sessionId, + [FromRoute, Required] GeneralCommandType command) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - /// <summary> - /// Issues a general command to a client. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="command">The command to send.</param> - /// <response code="204">General command sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Command/{command}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SendGeneralCommand( - [FromRoute, Required] string sessionId, - [FromRoute, Required] GeneralCommandType command) + var generalCommand = new GeneralCommand { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + Name = command, + ControllingUserId = currentSession.UserId + }; - var generalCommand = new GeneralCommand - { - Name = command, - ControllingUserId = currentSession.UserId - }; + await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None) + .ConfigureAwait(false); - await _sessionManager.SendGeneralCommand(currentSession.Id, sessionId, generalCommand, CancellationToken.None) - .ConfigureAwait(false); + return NoContent(); + } - return NoContent(); - } + /// <summary> + /// Issues a full general command to a client. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The <see cref="GeneralCommand"/>.</param> + /// <response code="204">Full general command sent to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Command")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SendFullGeneralCommand( + [FromRoute, Required] string sessionId, + [FromBody, Required] GeneralCommand command) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - /// <summary> - /// Issues a full general command to a client. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="command">The <see cref="GeneralCommand"/>.</param> - /// <response code="204">Full general command sent to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Command")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SendFullGeneralCommand( - [FromRoute, Required] string sessionId, - [FromBody, Required] GeneralCommand command) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(command); - ArgumentNullException.ThrowIfNull(command); + command.ControllingUserId = currentSession.UserId; - command.ControllingUserId = currentSession.UserId; + await _sessionManager.SendGeneralCommand( + currentSession.Id, + sessionId, + command, + CancellationToken.None) + .ConfigureAwait(false); - await _sessionManager.SendGeneralCommand( - currentSession.Id, - sessionId, - command, - CancellationToken.None) - .ConfigureAwait(false); + return NoContent(); + } - return NoContent(); + /// <summary> + /// Issues a command to a client to display a message to the user. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="command">The <see cref="MessageCommand" /> object containing Header, Message Text, and TimeoutMs.</param> + /// <response code="204">Message sent.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/Message")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SendMessageCommand( + [FromRoute, Required] string sessionId, + [FromBody, Required] MessageCommand command) + { + if (string.IsNullOrWhiteSpace(command.Header)) + { + command.Header = "Message from Server"; } - /// <summary> - /// Issues a command to a client to display a message to the user. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="command">The <see cref="MessageCommand" /> object containing Header, Message Text, and TimeoutMs.</param> - /// <response code="204">Message sent.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/Message")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SendMessageCommand( - [FromRoute, Required] string sessionId, - [FromBody, Required] MessageCommand command) - { - if (string.IsNullOrWhiteSpace(command.Header)) - { - command.Header = "Message from Server"; - } + await _sessionManager.SendMessageCommand( + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + sessionId, + command, + CancellationToken.None) + .ConfigureAwait(false); - await _sessionManager.SendMessageCommand( - await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), - sessionId, - command, - CancellationToken.None) - .ConfigureAwait(false); + return NoContent(); + } - return NoContent(); - } + /// <summary> + /// Adds an additional user to a session. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="userId">The user id.</param> + /// <response code="204">User added to session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/{sessionId}/User/{userId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult AddUserToSession( + [FromRoute, Required] string sessionId, + [FromRoute, Required] Guid userId) + { + _sessionManager.AddAdditionalUser(sessionId, userId); + return NoContent(); + } - /// <summary> - /// Adds an additional user to a session. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="userId">The user id.</param> - /// <response code="204">User added to session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/{sessionId}/User/{userId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult AddUserToSession( - [FromRoute, Required] string sessionId, - [FromRoute, Required] Guid userId) - { - _sessionManager.AddAdditionalUser(sessionId, userId); - return NoContent(); - } + /// <summary> + /// Removes an additional user from a session. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="userId">The user id.</param> + /// <response code="204">User removed from session.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Sessions/{sessionId}/User/{userId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RemoveUserFromSession( + [FromRoute, Required] string sessionId, + [FromRoute, Required] Guid userId) + { + _sessionManager.RemoveAdditionalUser(sessionId, userId); + return NoContent(); + } - /// <summary> - /// Removes an additional user from a session. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="userId">The user id.</param> - /// <response code="204">User removed from session.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Sessions/{sessionId}/User/{userId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RemoveUserFromSession( - [FromRoute, Required] string sessionId, - [FromRoute, Required] Guid userId) + /// <summary> + /// Updates capabilities for a device. + /// </summary> + /// <param name="id">The session id.</param> + /// <param name="playableMediaTypes">A list of playable media types, comma delimited. Audio, Video, Book, Photo.</param> + /// <param name="supportedCommands">A list of supported remote control commands, comma delimited.</param> + /// <param name="supportsMediaControl">Determines whether media can be played remotely..</param> + /// <param name="supportsSync">Determines whether sync is supported.</param> + /// <param name="supportsPersistentIdentifier">Determines whether the device supports a unique identifier.</param> + /// <response code="204">Capabilities posted.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Capabilities")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> PostCapabilities( + [FromQuery] string? id, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, + [FromQuery] bool supportsMediaControl = false, + [FromQuery] bool supportsSync = false, + [FromQuery] bool supportsPersistentIdentifier = true) + { + if (string.IsNullOrWhiteSpace(id)) { - _sessionManager.RemoveAdditionalUser(sessionId, userId); - return NoContent(); + id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); } - /// <summary> - /// Updates capabilities for a device. - /// </summary> - /// <param name="id">The session id.</param> - /// <param name="playableMediaTypes">A list of playable media types, comma delimited. Audio, Video, Book, Photo.</param> - /// <param name="supportedCommands">A list of supported remote control commands, comma delimited.</param> - /// <param name="supportsMediaControl">Determines whether media can be played remotely..</param> - /// <param name="supportsSync">Determines whether sync is supported.</param> - /// <param name="supportsPersistentIdentifier">Determines whether the device supports a unique identifier.</param> - /// <response code="204">Capabilities posted.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Capabilities")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> PostCapabilities( - [FromQuery] string? id, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, - [FromQuery] bool supportsMediaControl = false, - [FromQuery] bool supportsSync = false, - [FromQuery] bool supportsPersistentIdentifier = true) + _sessionManager.ReportCapabilities(id, new ClientCapabilities { - if (string.IsNullOrWhiteSpace(id)) - { - id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - } - - _sessionManager.ReportCapabilities(id, new ClientCapabilities - { - PlayableMediaTypes = playableMediaTypes, - SupportedCommands = supportedCommands, - SupportsMediaControl = supportsMediaControl, - SupportsSync = supportsSync, - SupportsPersistentIdentifier = supportsPersistentIdentifier - }); - return NoContent(); - } + PlayableMediaTypes = playableMediaTypes, + SupportedCommands = supportedCommands, + SupportsMediaControl = supportsMediaControl, + SupportsSync = supportsSync, + SupportsPersistentIdentifier = supportsPersistentIdentifier + }); + return NoContent(); + } - /// <summary> - /// Updates capabilities for a device. - /// </summary> - /// <param name="id">The session id.</param> - /// <param name="capabilities">The <see cref="ClientCapabilities"/>.</param> - /// <response code="204">Capabilities updated.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Capabilities/Full")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> PostFullCapabilities( - [FromQuery] string? id, - [FromBody, Required] ClientCapabilitiesDto capabilities) + /// <summary> + /// Updates capabilities for a device. + /// </summary> + /// <param name="id">The session id.</param> + /// <param name="capabilities">The <see cref="ClientCapabilities"/>.</param> + /// <response code="204">Capabilities updated.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Capabilities/Full")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> PostFullCapabilities( + [FromQuery] string? id, + [FromBody, Required] ClientCapabilitiesDto capabilities) + { + if (string.IsNullOrWhiteSpace(id)) { - if (string.IsNullOrWhiteSpace(id)) - { - id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - } + id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + } - _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); + _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); - return NoContent(); - } + return NoContent(); + } - /// <summary> - /// Reports that a session is viewing an item. - /// </summary> - /// <param name="sessionId">The session id.</param> - /// <param name="itemId">The item id.</param> - /// <response code="204">Session reported to server.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Viewing")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ReportViewing( - [FromQuery] string? sessionId, - [FromQuery, Required] string? itemId) - { - string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + /// <summary> + /// Reports that a session is viewing an item. + /// </summary> + /// <param name="sessionId">The session id.</param> + /// <param name="itemId">The item id.</param> + /// <response code="204">Session reported to server.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Viewing")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportViewing( + [FromQuery] string? sessionId, + [FromQuery, Required] string? itemId) + { + string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - _sessionManager.ReportNowViewingItem(session, itemId); - return NoContent(); - } + _sessionManager.ReportNowViewingItem(session, itemId); + return NoContent(); + } - /// <summary> - /// Reports that a session has ended. - /// </summary> - /// <response code="204">Session end reported to server.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Sessions/Logout")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> ReportSessionEnded() - { - await _sessionManager.Logout(User.GetToken()).ConfigureAwait(false); - return NoContent(); - } + /// <summary> + /// Reports that a session has ended. + /// </summary> + /// <response code="204">Session end reported to server.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Sessions/Logout")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> ReportSessionEnded() + { + await _sessionManager.Logout(User.GetToken()).ConfigureAwait(false); + return NoContent(); + } - /// <summary> - /// Get all auth providers. - /// </summary> - /// <response code="200">Auth providers retrieved.</response> - /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns> - [HttpGet("Auth/Providers")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders() - { - return _userManager.GetAuthenticationProviders(); - } + /// <summary> + /// Get all auth providers. + /// </summary> + /// <response code="200">Auth providers retrieved.</response> + /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns> + [HttpGet("Auth/Providers")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders() + { + return _userManager.GetAuthenticationProviders(); + } - /// <summary> - /// Get all password reset providers. - /// </summary> - /// <response code="200">Password reset providers retrieved.</response> - /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns> - [HttpGet("Auth/PasswordResetProviders")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.RequiresElevation)] - public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders() - { - return _userManager.GetPasswordResetProviders(); - } + /// <summary> + /// Get all password reset providers. + /// </summary> + /// <response code="200">Password reset providers retrieved.</response> + /// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns> + [HttpGet("Auth/PasswordResetProviders")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.RequiresElevation)] + public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders() + { + return _userManager.GetPasswordResetProviders(); } } diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index eec5779e6..aab390d1f 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -10,141 +10,140 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The startup wizard controller. +/// </summary> +[Authorize(Policy = Policies.FirstTimeSetupOrElevated)] +public class StartupController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _config; + private readonly IUserManager _userManager; + /// <summary> - /// The startup wizard controller. + /// Initializes a new instance of the <see cref="StartupController" /> class. /// </summary> - [Authorize(Policy = Policies.FirstTimeSetupOrElevated)] - public class StartupController : BaseJellyfinApiController + /// <param name="config">The server configuration manager.</param> + /// <param name="userManager">The user manager.</param> + public StartupController(IServerConfigurationManager config, IUserManager userManager) { - private readonly IServerConfigurationManager _config; - private readonly IUserManager _userManager; + _config = config; + _userManager = userManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="StartupController" /> class. - /// </summary> - /// <param name="config">The server configuration manager.</param> - /// <param name="userManager">The user manager.</param> - public StartupController(IServerConfigurationManager config, IUserManager userManager) - { - _config = config; - _userManager = userManager; - } + /// <summary> + /// Completes the startup wizard. + /// </summary> + /// <response code="204">Startup wizard completed.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Complete")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CompleteWizard() + { + _config.Configuration.IsStartupWizardCompleted = true; + _config.SaveConfiguration(); + return NoContent(); + } - /// <summary> - /// Completes the startup wizard. - /// </summary> - /// <response code="204">Startup wizard completed.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Complete")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult CompleteWizard() + /// <summary> + /// Gets the initial startup wizard configuration. + /// </summary> + /// <response code="200">Initial startup wizard configuration retrieved.</response> + /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns> + [HttpGet("Configuration")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<StartupConfigurationDto> GetStartupConfiguration() + { + return new StartupConfigurationDto { - _config.Configuration.IsStartupWizardCompleted = true; - _config.SaveConfiguration(); - return NoContent(); - } + UICulture = _config.Configuration.UICulture, + MetadataCountryCode = _config.Configuration.MetadataCountryCode, + PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage + }; + } - /// <summary> - /// Gets the initial startup wizard configuration. - /// </summary> - /// <response code="200">Initial startup wizard configuration retrieved.</response> - /// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns> - [HttpGet("Configuration")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<StartupConfigurationDto> GetStartupConfiguration() - { - return new StartupConfigurationDto - { - UICulture = _config.Configuration.UICulture, - MetadataCountryCode = _config.Configuration.MetadataCountryCode, - PreferredMetadataLanguage = _config.Configuration.PreferredMetadataLanguage - }; - } + /// <summary> + /// Sets the initial startup wizard configuration. + /// </summary> + /// <param name="startupConfiguration">The updated startup configuration.</param> + /// <response code="204">Configuration saved.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Configuration")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration) + { + _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty; + _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty; + _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty; + _config.SaveConfiguration(); + return NoContent(); + } - /// <summary> - /// Sets the initial startup wizard configuration. - /// </summary> - /// <param name="startupConfiguration">The updated startup configuration.</param> - /// <response code="204">Configuration saved.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Configuration")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration) - { - _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty; - _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty; - _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty; - _config.SaveConfiguration(); - return NoContent(); - } + /// <summary> + /// Sets remote access and UPnP. + /// </summary> + /// <param name="startupRemoteAccessDto">The startup remote access dto.</param> + /// <response code="204">Configuration saved.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("RemoteAccess")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto) + { + NetworkConfiguration settings = _config.GetNetworkConfiguration(); + settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess; + settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping; + _config.SaveConfiguration(NetworkConfigurationStore.StoreKey, settings); + return NoContent(); + } - /// <summary> - /// Sets remote access and UPnP. - /// </summary> - /// <param name="startupRemoteAccessDto">The startup remote access dto.</param> - /// <response code="204">Configuration saved.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("RemoteAccess")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto) + /// <summary> + /// Gets the first user. + /// </summary> + /// <response code="200">Initial user retrieved.</response> + /// <returns>The first user.</returns> + [HttpGet("User")] + [HttpGet("FirstUser", Name = "GetFirstUser_2")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<StartupUserDto> GetFirstUser() + { + // TODO: Remove this method when startup wizard no longer requires an existing user. + await _userManager.InitializeAsync().ConfigureAwait(false); + var user = _userManager.Users.First(); + return new StartupUserDto { - NetworkConfiguration settings = _config.GetNetworkConfiguration(); - settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess; - settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping; - _config.SaveConfiguration(NetworkConfigurationStore.StoreKey, settings); - return NoContent(); - } + Name = user.Username, + Password = user.Password + }; + } - /// <summary> - /// Gets the first user. - /// </summary> - /// <response code="200">Initial user retrieved.</response> - /// <returns>The first user.</returns> - [HttpGet("User")] - [HttpGet("FirstUser", Name = "GetFirstUser_2")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<StartupUserDto> GetFirstUser() - { - // TODO: Remove this method when startup wizard no longer requires an existing user. - await _userManager.InitializeAsync().ConfigureAwait(false); - var user = _userManager.Users.First(); - return new StartupUserDto - { - Name = user.Username, - Password = user.Password - }; - } + /// <summary> + /// Sets the user name and password. + /// </summary> + /// <param name="startupUserDto">The DTO containing username and password.</param> + /// <response code="204">Updated user name and password.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous update operation. + /// The task result contains a <see cref="NoContentResult"/> indicating success. + /// </returns> + [HttpPost("User")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto) + { + var user = _userManager.Users.First(); - /// <summary> - /// Sets the user name and password. - /// </summary> - /// <param name="startupUserDto">The DTO containing username and password.</param> - /// <response code="204">Updated user name and password.</response> - /// <returns> - /// A <see cref="Task" /> that represents the asynchronous update operation. - /// The task result contains a <see cref="NoContentResult"/> indicating success. - /// </returns> - [HttpPost("User")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto) + if (startupUserDto.Name is not null) { - var user = _userManager.Users.First(); - - if (startupUserDto.Name is not null) - { - user.Username = startupUserDto.Name; - } - - await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + user.Username = startupUserDto.Name; + } - if (!string.IsNullOrEmpty(startupUserDto.Password)) - { - await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); - } + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); - return NoContent(); + if (!string.IsNullOrEmpty(startupUserDto.Password)) + { + await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false); } + + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index 1288fb512..799be2ae8 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -16,141 +16,140 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Studios controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class StudiosController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + /// <summary> - /// Studios controller. + /// Initializes a new instance of the <see cref="StudiosController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class StudiosController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public StudiosController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } - /// <summary> - /// Initializes a new instance of the <see cref="StudiosController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - public StudiosController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - } + /// <summary> + /// Gets all studios from a given item, folder, or the entire library. + /// </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="searchTerm">Optional. Search term.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="userId">User id.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <param name="enableTotalRecordCount">Total record count.</param> + /// <response code="200">Studios returned.</response> + /// <returns>An <see cref="OkResult"/> containing the studios.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetStudios( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] string? searchTerm, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool? isFavorite, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] Guid? userId, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery] bool? enableImages = true, + [FromQuery] bool enableTotalRecordCount = true) + { + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - /// <summary> - /// Gets all studios from a given item, folder, or the entire library. - /// </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="searchTerm">Optional. Search term.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="userId">User id.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <param name="enableTotalRecordCount">Total record count.</param> - /// <response code="200">Studios returned.</response> - /// <returns>An <see cref="OkResult"/> containing the studios.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetStudios( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] string? searchTerm, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool? isFavorite, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] Guid? userId, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery] bool? enableImages = true, - [FromQuery] bool enableTotalRecordCount = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + User? user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + var parentItem = _libraryManager.GetParentItem(parentId, userId); - var parentItem = _libraryManager.GetParentItem(parentId, userId); + var query = new InternalItemsQuery(user) + { + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + StartIndex = startIndex, + Limit = limit, + IsFavorite = isFavorite, + NameLessThan = nameLessThan, + NameStartsWith = nameStartsWith, + NameStartsWithOrGreater = nameStartsWithOrGreater, + DtoOptions = dtoOptions, + SearchTerm = searchTerm, + EnableTotalRecordCount = enableTotalRecordCount + }; - var query = new InternalItemsQuery(user) + if (parentId.HasValue) + { + if (parentItem is Folder) { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - StartIndex = startIndex, - Limit = limit, - IsFavorite = isFavorite, - NameLessThan = nameLessThan, - NameStartsWith = nameStartsWith, - NameStartsWithOrGreater = nameStartsWithOrGreater, - DtoOptions = dtoOptions, - SearchTerm = searchTerm, - EnableTotalRecordCount = enableTotalRecordCount - }; - - if (parentId.HasValue) + query.AncestorIds = new[] { parentId.Value }; + } + else { - if (parentItem is Folder) - { - query.AncestorIds = new[] { parentId.Value }; - } - else - { - query.ItemIds = new[] { parentId.Value }; - } + query.ItemIds = new[] { parentId.Value }; } - - var result = _libraryManager.GetStudios(query); - var shouldIncludeItemTypes = includeItemTypes.Length != 0; - return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } - /// <summary> - /// Gets a studio by name. - /// </summary> - /// <param name="name">Studio name.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Studio returned.</response> - /// <returns>An <see cref="OkResult"/> containing the studio.</returns> - [HttpGet("{name}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId) - { - var dtoOptions = new DtoOptions().AddClientFields(User); + var result = _libraryManager.GetStudios(query); + var shouldIncludeItemTypes = includeItemTypes.Length != 0; + return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); + } - var item = _libraryManager.GetStudio(name); - if (userId.HasValue && !userId.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); + /// <summary> + /// Gets a studio by name. + /// </summary> + /// <param name="name">Studio name.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Studio returned.</response> + /// <returns>An <see cref="OkResult"/> containing the studio.</returns> + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId) + { + var dtoOptions = new DtoOptions().AddClientFields(User); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + var item = _libraryManager.GetStudio(name); + if (userId.HasValue && !userId.Equals(default)) + { + var user = _userManager.GetUserById(userId.Value); - return _dtoService.GetBaseItemDto(item, dtoOptions); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); } + + return _dtoService.GetBaseItemDto(item, dtoOptions); } } diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index c3ce1868e..fd0a71f9e 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -30,522 +30,521 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Subtitle controller. +/// </summary> +[Route("")] +public class SubtitleController : BaseJellyfinApiController { + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ILibraryManager _libraryManager; + private readonly ISubtitleManager _subtitleManager; + private readonly ISubtitleEncoder _subtitleEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly ILogger<SubtitleController> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="SubtitleController"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> + /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param> + /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param> + /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param> + public SubtitleController( + IServerConfigurationManager serverConfigurationManager, + ILibraryManager libraryManager, + ISubtitleManager subtitleManager, + ISubtitleEncoder subtitleEncoder, + IMediaSourceManager mediaSourceManager, + IProviderManager providerManager, + IFileSystem fileSystem, + ILogger<SubtitleController> logger) + { + _serverConfigurationManager = serverConfigurationManager; + _libraryManager = libraryManager; + _subtitleManager = subtitleManager; + _subtitleEncoder = subtitleEncoder; + _mediaSourceManager = mediaSourceManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _logger = logger; + } + /// <summary> - /// Subtitle controller. + /// Deletes an external subtitle file. /// </summary> - [Route("")] - public class SubtitleController : BaseJellyfinApiController + /// <param name="itemId">The item id.</param> + /// <param name="index">The index of the subtitle file.</param> + /// <response code="204">Subtitle deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Videos/{itemId}/Subtitles/{index}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<Task> DeleteSubtitle( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] int index) { - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly ILibraryManager _libraryManager; - private readonly ISubtitleManager _subtitleManager; - private readonly ISubtitleEncoder _subtitleEncoder; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IProviderManager _providerManager; - private readonly IFileSystem _fileSystem; - private readonly ILogger<SubtitleController> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="SubtitleController"/> class. - /// </summary> - /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> - /// <param name="subtitleManager">Instance of <see cref="ISubtitleManager"/> interface.</param> - /// <param name="subtitleEncoder">Instance of <see cref="ISubtitleEncoder"/> interface.</param> - /// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="providerManager">Instance of <see cref="IProviderManager"/> interface.</param> - /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> - /// <param name="logger">Instance of <see cref="ILogger{SubtitleController}"/> interface.</param> - public SubtitleController( - IServerConfigurationManager serverConfigurationManager, - ILibraryManager libraryManager, - ISubtitleManager subtitleManager, - ISubtitleEncoder subtitleEncoder, - IMediaSourceManager mediaSourceManager, - IProviderManager providerManager, - IFileSystem fileSystem, - ILogger<SubtitleController> logger) + var item = _libraryManager.GetItemById(itemId); + + if (item is null) { - _serverConfigurationManager = serverConfigurationManager; - _libraryManager = libraryManager; - _subtitleManager = subtitleManager; - _subtitleEncoder = subtitleEncoder; - _mediaSourceManager = mediaSourceManager; - _providerManager = providerManager; - _fileSystem = fileSystem; - _logger = logger; + return NotFound(); } - /// <summary> - /// Deletes an external subtitle file. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="index">The index of the subtitle file.</param> - /// <response code="204">Subtitle deleted.</response> - /// <response code="404">Item not found.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpDelete("Videos/{itemId}/Subtitles/{index}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<Task> DeleteSubtitle( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] int index) - { - var item = _libraryManager.GetItemById(itemId); + _subtitleManager.DeleteSubtitles(item, index); + return NoContent(); + } - if (item is null) - { - return NotFound(); - } + /// <summary> + /// Search remote subtitles. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="language">The language of the subtitles.</param> + /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param> + /// <response code="200">Subtitles retrieved.</response> + /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns> + [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string language, + [FromQuery] bool? isPerfectMatch) + { + var video = (Video)_libraryManager.GetItemById(itemId); - _subtitleManager.DeleteSubtitles(item, index); - return NoContent(); - } + return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Downloads a remote subtitle. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="subtitleId">The subtitle id.</param> + /// <response code="204">Subtitle downloaded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> DownloadRemoteSubtitles( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string subtitleId) + { + var video = (Video)_libraryManager.GetItemById(itemId); - /// <summary> - /// Search remote subtitles. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="language">The language of the subtitles.</param> - /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param> - /// <response code="200">Subtitles retrieved.</response> - /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns> - [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string language, - [FromQuery] bool? isPerfectMatch) + try { - var video = (Video)_libraryManager.GetItemById(itemId); + await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) + .ConfigureAwait(false); - return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); + _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); } - - /// <summary> - /// Downloads a remote subtitle. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="subtitleId">The subtitle id.</param> - /// <response code="204">Subtitle downloaded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> DownloadRemoteSubtitles( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string subtitleId) + catch (Exception ex) { - var video = (Video)_libraryManager.GetItemById(itemId); + _logger.LogError(ex, "Error downloading subtitles"); + } - try - { - await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) - .ConfigureAwait(false); + return NoContent(); + } - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error downloading subtitles"); - } + /// <summary> + /// Gets the remote subtitles. + /// </summary> + /// <param name="id">The item id.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns> + [HttpGet("Providers/Subtitles/Subtitles/{id}")] + [Authorize(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); - return NoContent(); - } + return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); + } - /// <summary> - /// Gets the remote subtitles. - /// </summary> - /// <param name="id">The item id.</param> - /// <response code="200">File returned.</response> - /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns> - [HttpGet("Providers/Subtitles/Subtitles/{id}")] - [Authorize(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); + /// <summary> + /// Gets subtitles in a specified format. + /// </summary> + /// <param name="routeItemId">The (route) item id.</param> + /// <param name="routeMediaSourceId">The (route) media source id.</param> + /// <param name="routeIndex">The (route) subtitle stream index.</param> + /// <param name="routeFormat">The (route) format of the returned subtitle.</param> + /// <param name="itemId">The item id.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="format">The format of the returned subtitle.</param> + /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> + /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> + /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> + [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("text/*")] + public async Task<ActionResult> GetSubtitle( + [FromRoute, Required] Guid routeItemId, + [FromRoute, Required] string routeMediaSourceId, + [FromRoute, Required] int routeIndex, + [FromRoute, Required] string routeFormat, + [FromQuery, ParameterObsolete] Guid? itemId, + [FromQuery, ParameterObsolete] string? mediaSourceId, + [FromQuery, ParameterObsolete] int? index, + [FromQuery, ParameterObsolete] string? format, + [FromQuery] long? endPositionTicks, + [FromQuery] bool copyTimestamps = false, + [FromQuery] bool addVttTimeMap = false, + [FromQuery] long startPositionTicks = 0) + { + // Set parameters to route value if not provided via query. + itemId ??= routeItemId; + mediaSourceId ??= routeMediaSourceId; + index ??= routeIndex; + format ??= routeFormat; - return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); + if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) + { + format = "json"; } - /// <summary> - /// Gets subtitles in a specified format. - /// </summary> - /// <param name="routeItemId">The (route) item id.</param> - /// <param name="routeMediaSourceId">The (route) media source id.</param> - /// <param name="routeIndex">The (route) subtitle stream index.</param> - /// <param name="routeFormat">The (route) format of the returned subtitle.</param> - /// <param name="itemId">The item id.</param> - /// <param name="mediaSourceId">The media source id.</param> - /// <param name="index">The subtitle stream index.</param> - /// <param name="format">The format of the returned subtitle.</param> - /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> - /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> - /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> - /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> - /// <response code="200">File returned.</response> - /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> - [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/Stream.{routeFormat}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile("text/*")] - public async Task<ActionResult> GetSubtitle( - [FromRoute, Required] Guid routeItemId, - [FromRoute, Required] string routeMediaSourceId, - [FromRoute, Required] int routeIndex, - [FromRoute, Required] string routeFormat, - [FromQuery, ParameterObsolete] Guid? itemId, - [FromQuery, ParameterObsolete] string? mediaSourceId, - [FromQuery, ParameterObsolete] int? index, - [FromQuery, ParameterObsolete] string? format, - [FromQuery] long? endPositionTicks, - [FromQuery] bool copyTimestamps = false, - [FromQuery] bool addVttTimeMap = false, - [FromQuery] long startPositionTicks = 0) + if (string.IsNullOrEmpty(format)) { - // Set parameters to route value if not provided via query. - itemId ??= routeItemId; - mediaSourceId ??= routeMediaSourceId; - index ??= routeIndex; - format ??= routeFormat; - - if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) - { - format = "json"; - } - - if (string.IsNullOrEmpty(format)) - { - var item = (Video)_libraryManager.GetItemById(itemId.Value); + var item = (Video)_libraryManager.GetItemById(itemId.Value); - var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); - var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) - .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); + var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); + var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) + .First(i => string.Equals(i.Id, mediaSourceId ?? idString, StringComparison.Ordinal)); - var subtitleStream = mediaSource.MediaStreams - .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); + var subtitleStream = mediaSource.MediaStreams + .First(i => i.Type == MediaStreamType.Subtitle && i.Index == index); - return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); - } + return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path)); + } - if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) + if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap) + { + Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) { - Stream stream = await EncodeSubtitles(itemId.Value, mediaSourceId, index.Value, format, startPositionTicks, endPositionTicks, copyTimestamps).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) - { - using var reader = new StreamReader(stream); + using var reader = new StreamReader(stream); - var text = await reader.ReadToEndAsync().ConfigureAwait(false); + var text = await reader.ReadToEndAsync().ConfigureAwait(false); - text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal); + text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal); - return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format)); - } + return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format)); } - - return File( - await EncodeSubtitles( - itemId.Value, - mediaSourceId, - index.Value, - format, - startPositionTicks, - endPositionTicks, - copyTimestamps).ConfigureAwait(false), - MimeTypes.GetMimeType("file." + format)); } - /// <summary> - /// Gets subtitles in a specified format. - /// </summary> - /// <param name="routeItemId">The (route) item id.</param> - /// <param name="routeMediaSourceId">The (route) media source id.</param> - /// <param name="routeIndex">The (route) subtitle stream index.</param> - /// <param name="routeStartPositionTicks">The (route) start position of the subtitle in ticks.</param> - /// <param name="routeFormat">The (route) format of the returned subtitle.</param> - /// <param name="itemId">The item id.</param> - /// <param name="mediaSourceId">The media source id.</param> - /// <param name="index">The subtitle stream index.</param> - /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> - /// <param name="format">The format of the returned subtitle.</param> - /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> - /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> - /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> - /// <response code="200">File returned.</response> - /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> - [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile("text/*")] - public Task<ActionResult> GetSubtitleWithTicks( - [FromRoute, Required] Guid routeItemId, - [FromRoute, Required] string routeMediaSourceId, - [FromRoute, Required] int routeIndex, - [FromRoute, Required] long routeStartPositionTicks, - [FromRoute, Required] string routeFormat, - [FromQuery, ParameterObsolete] Guid? itemId, - [FromQuery, ParameterObsolete] string? mediaSourceId, - [FromQuery, ParameterObsolete] int? index, - [FromQuery, ParameterObsolete] long? startPositionTicks, - [FromQuery, ParameterObsolete] string? format, - [FromQuery] long? endPositionTicks, - [FromQuery] bool copyTimestamps = false, - [FromQuery] bool addVttTimeMap = false) - { - return GetSubtitle( - routeItemId, - routeMediaSourceId, - routeIndex, - routeFormat, - itemId, + return File( + await EncodeSubtitles( + itemId.Value, mediaSourceId, - index, + index.Value, format, + startPositionTicks, endPositionTicks, - copyTimestamps, - addVttTimeMap, - startPositionTicks ?? routeStartPositionTicks); - } + copyTimestamps).ConfigureAwait(false), + MimeTypes.GetMimeType("file." + format)); + } - /// <summary> - /// Gets an HLS subtitle playlist. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="index">The subtitle stream index.</param> - /// <param name="mediaSourceId">The media source id.</param> - /// <param name="segmentLength">The subtitle segment length.</param> - /// <response code="200">Subtitle playlist retrieved.</response> - /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> - [HttpGet("Videos/{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, - [FromRoute, Required] int index, - [FromRoute, Required] string mediaSourceId, - [FromQuery, Required] int segmentLength) - { - var item = (Video)_libraryManager.GetItemById(itemId); + /// <summary> + /// Gets subtitles in a specified format. + /// </summary> + /// <param name="routeItemId">The (route) item id.</param> + /// <param name="routeMediaSourceId">The (route) media source id.</param> + /// <param name="routeIndex">The (route) subtitle stream index.</param> + /// <param name="routeStartPositionTicks">The (route) start position of the subtitle in ticks.</param> + /// <param name="routeFormat">The (route) format of the returned subtitle.</param> + /// <param name="itemId">The item id.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> + /// <param name="format">The format of the returned subtitle.</param> + /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> + /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> + /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <response code="200">File returned.</response> + /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> + [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("text/*")] + public Task<ActionResult> GetSubtitleWithTicks( + [FromRoute, Required] Guid routeItemId, + [FromRoute, Required] string routeMediaSourceId, + [FromRoute, Required] int routeIndex, + [FromRoute, Required] long routeStartPositionTicks, + [FromRoute, Required] string routeFormat, + [FromQuery, ParameterObsolete] Guid? itemId, + [FromQuery, ParameterObsolete] string? mediaSourceId, + [FromQuery, ParameterObsolete] int? index, + [FromQuery, ParameterObsolete] long? startPositionTicks, + [FromQuery, ParameterObsolete] string? format, + [FromQuery] long? endPositionTicks, + [FromQuery] bool copyTimestamps = false, + [FromQuery] bool addVttTimeMap = false) + { + return GetSubtitle( + routeItemId, + routeMediaSourceId, + routeIndex, + routeFormat, + itemId, + mediaSourceId, + index, + format, + endPositionTicks, + copyTimestamps, + addVttTimeMap, + startPositionTicks ?? routeStartPositionTicks); + } - var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); + /// <summary> + /// Gets an HLS subtitle playlist. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="index">The subtitle stream index.</param> + /// <param name="mediaSourceId">The media source id.</param> + /// <param name="segmentLength">The subtitle segment length.</param> + /// <response code="200">Subtitle playlist retrieved.</response> + /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> + [HttpGet("Videos/{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, + [FromRoute, Required] int index, + [FromRoute, Required] string mediaSourceId, + [FromQuery, Required] int segmentLength) + { + var item = (Video)_libraryManager.GetItemById(itemId); - var runtime = mediaSource.RunTimeTicks ?? -1; + var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); - if (runtime <= 0) - { - throw new ArgumentException("HLS Subtitles are not supported for this media."); - } + var runtime = mediaSource.RunTimeTicks ?? -1; - var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks; - if (segmentLengthTicks <= 0) - { - throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)"); - } + if (runtime <= 0) + { + throw new ArgumentException("HLS Subtitles are not supported for this media."); + } - var builder = new StringBuilder(); - builder.AppendLine("#EXTM3U") - .Append("#EXT-X-TARGETDURATION:") - .Append(segmentLength) - .AppendLine() - .AppendLine("#EXT-X-VERSION:3") - .AppendLine("#EXT-X-MEDIA-SEQUENCE:0") - .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); + var segmentLengthTicks = TimeSpan.FromSeconds(segmentLength).Ticks; + if (segmentLengthTicks <= 0) + { + throw new ArgumentException("segmentLength was not given, or it was given incorrectly. (It should be bigger than 0)"); + } - long positionTicks = 0; + var builder = new StringBuilder(); + builder.AppendLine("#EXTM3U") + .Append("#EXT-X-TARGETDURATION:") + .Append(segmentLength) + .AppendLine() + .AppendLine("#EXT-X-VERSION:3") + .AppendLine("#EXT-X-MEDIA-SEQUENCE:0") + .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); - var accessToken = User.GetToken(); + long positionTicks = 0; - while (positionTicks < runtime) - { - var remaining = runtime - positionTicks; - var lengthTicks = Math.Min(remaining, segmentLengthTicks); + var accessToken = User.GetToken(); - builder.Append("#EXTINF:") - .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds) - .Append(',') - .AppendLine(); + while (positionTicks < runtime) + { + var remaining = runtime - positionTicks; + var lengthTicks = Math.Min(remaining, segmentLengthTicks); - var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); + builder.Append("#EXTINF:") + .Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds) + .Append(',') + .AppendLine(); - var url = string.Format( - CultureInfo.InvariantCulture, - "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}", - positionTicks.ToString(CultureInfo.InvariantCulture), - endPositionTicks.ToString(CultureInfo.InvariantCulture), - accessToken); + var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); - builder.AppendLine(url); + var url = string.Format( + CultureInfo.InvariantCulture, + "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&api_key={2}", + positionTicks.ToString(CultureInfo.InvariantCulture), + endPositionTicks.ToString(CultureInfo.InvariantCulture), + accessToken); - positionTicks += segmentLengthTicks; - } + builder.AppendLine(url); - builder.AppendLine("#EXT-X-ENDLIST"); - return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + positionTicks += segmentLengthTicks; } - /// <summary> - /// Upload an external subtitle file. - /// </summary> - /// <param name="itemId">The item the subtitle belongs to.</param> - /// <param name="body">The request body.</param> - /// <response code="204">Subtitle uploaded.</response> - /// <returns>A <see cref="NoContentResult"/>.</returns> - [HttpPost("Videos/{itemId}/Subtitles")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> UploadSubtitle( - [FromRoute, Required] Guid itemId, - [FromBody, Required] UploadSubtitleDto body) - { - var video = (Video)_libraryManager.GetItemById(itemId); - var data = Convert.FromBase64String(body.Data); - var memoryStream = new MemoryStream(data, 0, data.Length, false, true); - await using (memoryStream.ConfigureAwait(false)) - { - await _subtitleManager.UploadSubtitle( - video, - new SubtitleResponse - { - Format = body.Format, - Language = body.Language, - IsForced = body.IsForced, - Stream = memoryStream - }).ConfigureAwait(false); - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); - - return NoContent(); - } - } + builder.AppendLine("#EXT-X-ENDLIST"); + return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } - /// <summary> - /// Encodes a subtitle in the specified format. - /// </summary> - /// <param name="id">The media id.</param> - /// <param name="mediaSourceId">The source media id.</param> - /// <param name="index">The subtitle index.</param> - /// <param name="format">The format to convert to.</param> - /// <param name="startPositionTicks">The start position in ticks.</param> - /// <param name="endPositionTicks">The end position in ticks.</param> - /// <param name="copyTimestamps">Whether to copy the timestamps.</param> - /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns> - private Task<Stream> EncodeSubtitles( - Guid id, - string? mediaSourceId, - int index, - string format, - long startPositionTicks, - long? endPositionTicks, - bool copyTimestamps) + /// <summary> + /// Upload an external subtitle file. + /// </summary> + /// <param name="itemId">The item the subtitle belongs to.</param> + /// <param name="body">The request body.</param> + /// <response code="204">Subtitle uploaded.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Videos/{itemId}/Subtitles")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> UploadSubtitle( + [FromRoute, Required] Guid itemId, + [FromBody, Required] UploadSubtitleDto body) + { + var video = (Video)_libraryManager.GetItemById(itemId); + var data = Convert.FromBase64String(body.Data); + var memoryStream = new MemoryStream(data, 0, data.Length, false, true); + await using (memoryStream.ConfigureAwait(false)) { - var item = _libraryManager.GetItemById(id); + await _subtitleManager.UploadSubtitle( + video, + new SubtitleResponse + { + Format = body.Format, + Language = body.Language, + IsForced = body.IsForced, + Stream = memoryStream + }).ConfigureAwait(false); + _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); - return _subtitleEncoder.GetSubtitles( - item, - mediaSourceId, - index, - format, - startPositionTicks, - endPositionTicks ?? 0, - copyTimestamps, - CancellationToken.None); + return NoContent(); } + } - /// <summary> - /// Gets a list of available fallback font files. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns> - [HttpGet("FallbackFont/Fonts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public IEnumerable<FontFile> GetFallbackFontList() - { - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var fallbackFontPath = encodingOptions.FallbackFontPath; + /// <summary> + /// Encodes a subtitle in the specified format. + /// </summary> + /// <param name="id">The media id.</param> + /// <param name="mediaSourceId">The source media id.</param> + /// <param name="index">The subtitle index.</param> + /// <param name="format">The format to convert to.</param> + /// <param name="startPositionTicks">The start position in ticks.</param> + /// <param name="endPositionTicks">The end position in ticks.</param> + /// <param name="copyTimestamps">Whether to copy the timestamps.</param> + /// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns> + private Task<Stream> EncodeSubtitles( + Guid id, + string? mediaSourceId, + int index, + string format, + long startPositionTicks, + long? endPositionTicks, + bool copyTimestamps) + { + var item = _libraryManager.GetItemById(id); + + return _subtitleEncoder.GetSubtitles( + item, + mediaSourceId, + index, + format, + startPositionTicks, + endPositionTicks ?? 0, + copyTimestamps, + CancellationToken.None); + } + + /// <summary> + /// Gets a list of available fallback font files. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>An array of <see cref="FontFile"/> with the available font files.</returns> + [HttpGet("FallbackFont/Fonts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public IEnumerable<FontFile> GetFallbackFontList() + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var fallbackFontPath = encodingOptions.FallbackFontPath; - if (!string.IsNullOrEmpty(fallbackFontPath)) + if (!string.IsNullOrEmpty(fallbackFontPath)) + { + var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false); + var fontFiles = files + .Select(i => new FontFile + { + Name = i.Name, + Size = i.Length, + DateCreated = _fileSystem.GetCreationTimeUtc(i), + DateModified = _fileSystem.GetLastWriteTimeUtc(i) + }) + .OrderBy(i => i.Size) + .ThenBy(i => i.Name) + .ThenByDescending(i => i.DateModified) + .ThenByDescending(i => i.DateCreated); + // max total size 20M + const int MaxSize = 20971520; + var sizeCounter = 0L; + foreach (var fontFile in fontFiles) { - var files = _fileSystem.GetFiles(fallbackFontPath, new[] { ".woff", ".woff2", ".ttf", ".otf" }, false, false); - var fontFiles = files - .Select(i => new FontFile - { - Name = i.Name, - Size = i.Length, - DateCreated = _fileSystem.GetCreationTimeUtc(i), - DateModified = _fileSystem.GetLastWriteTimeUtc(i) - }) - .OrderBy(i => i.Size) - .ThenBy(i => i.Name) - .ThenByDescending(i => i.DateModified) - .ThenByDescending(i => i.DateCreated); - // max total size 20M - const int MaxSize = 20971520; - var sizeCounter = 0L; - foreach (var fontFile in fontFiles) + sizeCounter += fontFile.Size; + if (sizeCounter >= MaxSize) { - sizeCounter += fontFile.Size; - if (sizeCounter >= MaxSize) - { - _logger.LogWarning("Some fonts will not be sent due to size limitations"); - yield break; - } - - yield return fontFile; + _logger.LogWarning("Some fonts will not be sent due to size limitations"); + yield break; } + + yield return fontFile; } - else - { - _logger.LogWarning("The path of fallback font folder has not been set"); - encodingOptions.EnableFallbackFont = false; - } } + else + { + _logger.LogWarning("The path of fallback font folder has not been set"); + encodingOptions.EnableFallbackFont = false; + } + } - /// <summary> - /// Gets a fallback font file. - /// </summary> - /// <param name="name">The name of the fallback font file to get.</param> - /// <response code="200">Fallback font file retrieved.</response> - /// <returns>The fallback font file.</returns> - [HttpGet("FallbackFont/Fonts/{name}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile("font/*")] - public ActionResult GetFallbackFont([FromRoute, Required] string name) + /// <summary> + /// Gets a fallback font file. + /// </summary> + /// <param name="name">The name of the fallback font file to get.</param> + /// <response code="200">Fallback font file retrieved.</response> + /// <returns>The fallback font file.</returns> + [HttpGet("FallbackFont/Fonts/{name}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("font/*")] + public ActionResult GetFallbackFont([FromRoute, Required] string name) + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var fallbackFontPath = encodingOptions.FallbackFontPath; + + if (!string.IsNullOrEmpty(fallbackFontPath)) { - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var fallbackFontPath = encodingOptions.FallbackFontPath; + var fontFile = _fileSystem.GetFiles(fallbackFontPath) + .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + var fileSize = fontFile?.Length; - if (!string.IsNullOrEmpty(fallbackFontPath)) + if (fontFile is not null && fileSize is not null && fileSize > 0) { - var fontFile = _fileSystem.GetFiles(fallbackFontPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); - var fileSize = fontFile?.Length; - - if (fontFile is not null && fileSize is not null && fileSize > 0) - { - _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize); - return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); - } - else - { - _logger.LogWarning("The selected font is null or empty"); - } + _logger.LogDebug("Fallback font size is {FileSize} Bytes", fileSize); + return PhysicalFile(fontFile.FullName, MimeTypes.GetMimeType(fontFile.FullName)); } else { - _logger.LogWarning("The path of fallback font folder has not been set"); - encodingOptions.EnableFallbackFont = false; + _logger.LogWarning("The selected font is null or empty"); } - - // returning HTTP 204 will break the SubtitlesOctopus - return Ok(); } + else + { + _logger.LogWarning("The path of fallback font folder has not been set"); + encodingOptions.EnableFallbackFont = false; + } + + // returning HTTP 204 will break the SubtitlesOctopus + return Ok(); } } diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index 1cf528153..c5c429757 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -13,80 +13,79 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The suggestions controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class SuggestionsController : BaseJellyfinApiController { + private readonly IDtoService _dtoService; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + /// <summary> - /// The suggestions controller. + /// Initializes a new instance of the <see cref="SuggestionsController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class SuggestionsController : BaseJellyfinApiController + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public SuggestionsController( + IDtoService dtoService, + IUserManager userManager, + ILibraryManager libraryManager) { - private readonly IDtoService _dtoService; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; + _dtoService = dtoService; + _userManager = userManager; + _libraryManager = libraryManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="SuggestionsController"/> class. - /// </summary> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public SuggestionsController( - IDtoService dtoService, - IUserManager userManager, - ILibraryManager libraryManager) - { - _dtoService = dtoService; - _userManager = userManager; - _libraryManager = libraryManager; - } + /// <summary> + /// Gets suggestions. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="mediaType">The media types.</param> + /// <param name="type">The type.</param> + /// <param name="startIndex">Optional. The start index.</param> + /// <param name="limit">Optional. The limit.</param> + /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param> + /// <response code="200">Suggestions returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns> + [HttpGet("Users/{userId}/Suggestions")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( + [FromRoute, Required] Guid userId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool enableTotalRecordCount = false) + { + var user = userId.Equals(default) + ? null + : _userManager.GetUserById(userId); - /// <summary> - /// Gets suggestions. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="mediaType">The media types.</param> - /// <param name="type">The type.</param> - /// <param name="startIndex">Optional. The start index.</param> - /// <param name="limit">Optional. The limit.</param> - /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param> - /// <response code="200">Suggestions returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns> - [HttpGet("Users/{userId}/Suggestions")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( - [FromRoute, Required] Guid userId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] type, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool enableTotalRecordCount = false) + var dtoOptions = new DtoOptions().AddClientFields(User); + var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { - var user = userId.Equals(default) - ? null - : _userManager.GetUserById(userId); - - var dtoOptions = new DtoOptions().AddClientFields(User); - var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) - { - OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, - MediaTypes = mediaType, - IncludeItemTypes = type, - IsVirtualItem = false, - StartIndex = startIndex, - Limit = limit, - DtoOptions = dtoOptions, - EnableTotalRecordCount = enableTotalRecordCount, - Recursive = true - }); + OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) }, + MediaTypes = mediaType, + IncludeItemTypes = type, + IsVirtualItem = false, + StartIndex = startIndex, + Limit = limit, + DtoOptions = dtoOptions, + EnableTotalRecordCount = enableTotalRecordCount, + Recursive = true + }); - var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); + var dtoList = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user); - return new QueryResult<BaseItemDto>( - startIndex, - result.TotalRecordCount, - dtoList); - } + return new QueryResult<BaseItemDto>( + startIndex, + result.TotalRecordCount, + dtoList); } } diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index 99347246e..23abba7dc 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -16,409 +16,408 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The sync play controller. +/// </summary> +[Authorize(Policy = Policies.SyncPlayHasAccess)] +public class SyncPlayController : BaseJellyfinApiController { + private readonly ISessionManager _sessionManager; + private readonly ISyncPlayManager _syncPlayManager; + private readonly IUserManager _userManager; + /// <summary> - /// The sync play controller. + /// Initializes a new instance of the <see cref="SyncPlayController"/> class. /// </summary> - [Authorize(Policy = Policies.SyncPlayHasAccess)] - public class SyncPlayController : BaseJellyfinApiController + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public SyncPlayController( + ISessionManager sessionManager, + ISyncPlayManager syncPlayManager, + IUserManager userManager) { - private readonly ISessionManager _sessionManager; - private readonly ISyncPlayManager _syncPlayManager; - private readonly IUserManager _userManager; - - /// <summary> - /// Initializes a new instance of the <see cref="SyncPlayController"/> class. - /// </summary> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - /// <param name="syncPlayManager">Instance of the <see cref="ISyncPlayManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public SyncPlayController( - ISessionManager sessionManager, - ISyncPlayManager syncPlayManager, - IUserManager userManager) - { - _sessionManager = sessionManager; - _syncPlayManager = syncPlayManager; - _userManager = userManager; - } + _sessionManager = sessionManager; + _syncPlayManager = syncPlayManager; + _userManager = userManager; + } - /// <summary> - /// Create a new SyncPlay group. - /// </summary> - /// <param name="requestData">The settings of the new group.</param> - /// <response code="204">New group created.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("New")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayCreateGroup)] - public async Task<ActionResult> SyncPlayCreateGroup( - [FromBody, Required] NewGroupRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new NewGroupRequest(requestData.GroupName); - _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Create a new SyncPlay group. + /// </summary> + /// <param name="requestData">The settings of the new group.</param> + /// <response code="204">New group created.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("New")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayCreateGroup)] + public async Task<ActionResult> SyncPlayCreateGroup( + [FromBody, Required] NewGroupRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new NewGroupRequest(requestData.GroupName); + _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Join an existing SyncPlay group. - /// </summary> - /// <param name="requestData">The group to join.</param> - /// <response code="204">Group join successful.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Join")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayJoinGroup)] - public async Task<ActionResult> SyncPlayJoinGroup( - [FromBody, Required] JoinGroupRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new JoinGroupRequest(requestData.GroupId); - _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Join an existing SyncPlay group. + /// </summary> + /// <param name="requestData">The group to join.</param> + /// <response code="204">Group join successful.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Join")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayJoinGroup)] + public async Task<ActionResult> SyncPlayJoinGroup( + [FromBody, Required] JoinGroupRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new JoinGroupRequest(requestData.GroupId); + _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Leave the joined SyncPlay group. - /// </summary> - /// <response code="204">Group leave successful.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Leave")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayLeaveGroup() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new LeaveGroupRequest(); - _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Leave the joined SyncPlay group. + /// </summary> + /// <response code="204">Group leave successful.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Leave")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayLeaveGroup() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new LeaveGroupRequest(); + _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Gets all SyncPlay groups. - /// </summary> - /// <response code="200">Groups returned.</response> - /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns> - [HttpGet("List")] - [ProducesResponseType(StatusCodes.Status200OK)] - [Authorize(Policy = Policies.SyncPlayJoinGroup)] - public async Task<ActionResult<IEnumerable<GroupInfoDto>>> SyncPlayGetGroups() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new ListGroupsRequest(); - return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable()); - } + /// <summary> + /// Gets all SyncPlay groups. + /// </summary> + /// <response code="200">Groups returned.</response> + /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns> + [HttpGet("List")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = Policies.SyncPlayJoinGroup)] + public async Task<ActionResult<IEnumerable<GroupInfoDto>>> SyncPlayGetGroups() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new ListGroupsRequest(); + return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest).AsEnumerable()); + } - /// <summary> - /// Request to set new playlist in SyncPlay group. - /// </summary> - /// <param name="requestData">The new playlist to play in the group.</param> - /// <response code="204">Queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("SetNewQueue")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySetNewQueue( - [FromBody, Required] PlayRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new PlayGroupRequest( - requestData.PlayingQueue, - requestData.PlayingItemPosition, - requestData.StartPositionTicks); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to set new playlist in SyncPlay group. + /// </summary> + /// <param name="requestData">The new playlist to play in the group.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetNewQueue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySetNewQueue( + [FromBody, Required] PlayRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new PlayGroupRequest( + requestData.PlayingQueue, + requestData.PlayingItemPosition, + requestData.StartPositionTicks); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to change playlist item in SyncPlay group. - /// </summary> - /// <param name="requestData">The new item to play.</param> - /// <response code="204">Queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("SetPlaylistItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySetPlaylistItem( - [FromBody, Required] SetPlaylistItemRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to change playlist item in SyncPlay group. + /// </summary> + /// <param name="requestData">The new item to play.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetPlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySetPlaylistItem( + [FromBody, Required] SetPlaylistItemRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to remove items from the playlist in SyncPlay group. - /// </summary> - /// <param name="requestData">The items to remove.</param> - /// <response code="204">Queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("RemoveFromPlaylist")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayRemoveFromPlaylist( - [FromBody, Required] RemoveFromPlaylistRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds, requestData.ClearPlaylist, requestData.ClearPlayingItem); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to remove items from the playlist in SyncPlay group. + /// </summary> + /// <param name="requestData">The items to remove.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("RemoveFromPlaylist")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayRemoveFromPlaylist( + [FromBody, Required] RemoveFromPlaylistRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds, requestData.ClearPlaylist, requestData.ClearPlayingItem); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to move an item in the playlist in SyncPlay group. - /// </summary> - /// <param name="requestData">The new position for the item.</param> - /// <response code="204">Queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("MovePlaylistItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayMovePlaylistItem( - [FromBody, Required] MovePlaylistItemRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to move an item in the playlist in SyncPlay group. + /// </summary> + /// <param name="requestData">The new position for the item.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("MovePlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayMovePlaylistItem( + [FromBody, Required] MovePlaylistItemRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to queue items to the playlist of a SyncPlay group. - /// </summary> - /// <param name="requestData">The items to add.</param> - /// <response code="204">Queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Queue")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayQueue( - [FromBody, Required] QueueRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to queue items to the playlist of a SyncPlay group. + /// </summary> + /// <param name="requestData">The items to add.</param> + /// <response code="204">Queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Queue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayQueue( + [FromBody, Required] QueueRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request unpause in SyncPlay group. - /// </summary> - /// <response code="204">Unpause update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Unpause")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayUnpause() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new UnpauseGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request unpause in SyncPlay group. + /// </summary> + /// <response code="204">Unpause update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Unpause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayUnpause() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new UnpauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request pause in SyncPlay group. - /// </summary> - /// <response code="204">Pause update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Pause")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayPause() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new PauseGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request pause in SyncPlay group. + /// </summary> + /// <response code="204">Pause update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Pause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayPause() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new PauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request stop in SyncPlay group. - /// </summary> - /// <response code="204">Stop update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Stop")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayStop() - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new StopGroupRequest(); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request stop in SyncPlay group. + /// </summary> + /// <response code="204">Stop update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Stop")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayStop() + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new StopGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request seek in SyncPlay group. - /// </summary> - /// <param name="requestData">The new playback position.</param> - /// <response code="204">Seek update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Seek")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySeek( - [FromBody, Required] SeekRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request seek in SyncPlay group. + /// </summary> + /// <param name="requestData">The new playback position.</param> + /// <response code="204">Seek update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Seek")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySeek( + [FromBody, Required] SeekRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Notify SyncPlay group that member is buffering. - /// </summary> - /// <param name="requestData">The player status.</param> - /// <response code="204">Group state update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Buffering")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayBuffering( - [FromBody, Required] BufferRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new BufferGroupRequest( - requestData.When, - requestData.PositionTicks, - requestData.IsPlaying, - requestData.PlaylistItemId); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Notify SyncPlay group that member is buffering. + /// </summary> + /// <param name="requestData">The player status.</param> + /// <response code="204">Group state update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Buffering")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayBuffering( + [FromBody, Required] BufferRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new BufferGroupRequest( + requestData.When, + requestData.PositionTicks, + requestData.IsPlaying, + requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Notify SyncPlay group that member is ready for playback. - /// </summary> - /// <param name="requestData">The player status.</param> - /// <response code="204">Group state update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Ready")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayReady( - [FromBody, Required] ReadyRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new ReadyGroupRequest( - requestData.When, - requestData.PositionTicks, - requestData.IsPlaying, - requestData.PlaylistItemId); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Notify SyncPlay group that member is ready for playback. + /// </summary> + /// <param name="requestData">The player status.</param> + /// <response code="204">Group state update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Ready")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayReady( + [FromBody, Required] ReadyRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new ReadyGroupRequest( + requestData.When, + requestData.PositionTicks, + requestData.IsPlaying, + requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request SyncPlay group to ignore member during group-wait. - /// </summary> - /// <param name="requestData">The settings to set.</param> - /// <response code="204">Member state updated.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("SetIgnoreWait")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySetIgnoreWait( - [FromBody, Required] IgnoreWaitRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request SyncPlay group to ignore member during group-wait. + /// </summary> + /// <param name="requestData">The settings to set.</param> + /// <response code="204">Member state updated.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetIgnoreWait")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySetIgnoreWait( + [FromBody, Required] IgnoreWaitRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request next item in SyncPlay group. - /// </summary> - /// <param name="requestData">The current item information.</param> - /// <response code="204">Next item update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("NextItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayNextItem( - [FromBody, Required] NextItemRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request next item in SyncPlay group. + /// </summary> + /// <param name="requestData">The current item information.</param> + /// <response code="204">Next item update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("NextItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayNextItem( + [FromBody, Required] NextItemRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request previous item in SyncPlay group. - /// </summary> - /// <param name="requestData">The current item information.</param> - /// <response code="204">Previous item update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("PreviousItem")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlayPreviousItem( - [FromBody, Required] PreviousItemRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request previous item in SyncPlay group. + /// </summary> + /// <param name="requestData">The current item information.</param> + /// <response code="204">Previous item update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("PreviousItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlayPreviousItem( + [FromBody, Required] PreviousItemRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to set repeat mode in SyncPlay group. - /// </summary> - /// <param name="requestData">The new repeat mode.</param> - /// <response code="204">Play queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("SetRepeatMode")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySetRepeatMode( - [FromBody, Required] SetRepeatModeRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to set repeat mode in SyncPlay group. + /// </summary> + /// <param name="requestData">The new repeat mode.</param> + /// <response code="204">Play queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetRepeatMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySetRepeatMode( + [FromBody, Required] SetRepeatModeRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Request to set shuffle mode in SyncPlay group. - /// </summary> - /// <param name="requestData">The new shuffle mode.</param> - /// <response code="204">Play queue update sent to all group members.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("SetShuffleMode")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [Authorize(Policy = Policies.SyncPlayIsInGroup)] - public async Task<ActionResult> SyncPlaySetShuffleMode( - [FromBody, Required] SetShuffleModeRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Request to set shuffle mode in SyncPlay group. + /// </summary> + /// <param name="requestData">The new shuffle mode.</param> + /// <response code="204">Play queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetShuffleMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [Authorize(Policy = Policies.SyncPlayIsInGroup)] + public async Task<ActionResult> SyncPlaySetShuffleMode( + [FromBody, Required] SetShuffleModeRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } - /// <summary> - /// Update session ping. - /// </summary> - /// <param name="requestData">The new ping.</param> - /// <response code="204">Ping updated.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("Ping")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> SyncPlayPing( - [FromBody, Required] PingRequestDto requestData) - { - var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var syncPlayRequest = new PingGroupRequest(requestData.Ping); - _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); - return NoContent(); - } + /// <summary> + /// Update session ping. + /// </summary> + /// <param name="requestData">The new ping.</param> + /// <response code="204">Ping updated.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Ping")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task<ActionResult> SyncPlayPing( + [FromBody, Required] PingRequestDto requestData) + { + var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); + var syncPlayRequest = new PingGroupRequest(requestData.Ping); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 2d594293e..b0b2e2d6d 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -20,204 +20,203 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The system controller. +/// </summary> +public class SystemController : BaseJellyfinApiController { + private readonly IServerApplicationHost _appHost; + private readonly IApplicationPaths _appPaths; + private readonly IFileSystem _fileSystem; + private readonly INetworkManager _network; + private readonly ILogger<SystemController> _logger; + /// <summary> - /// The system controller. + /// Initializes a new instance of the <see cref="SystemController"/> class. /// </summary> - public class SystemController : BaseJellyfinApiController + /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> + /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> + /// <param name="network">Instance of <see cref="INetworkManager"/> interface.</param> + /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param> + public SystemController( + IServerConfigurationManager serverConfigurationManager, + IServerApplicationHost appHost, + IFileSystem fileSystem, + INetworkManager network, + ILogger<SystemController> logger) { - private readonly IServerApplicationHost _appHost; - private readonly IApplicationPaths _appPaths; - private readonly IFileSystem _fileSystem; - private readonly INetworkManager _network; - private readonly ILogger<SystemController> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="SystemController"/> class. - /// </summary> - /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> - /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> - /// <param name="network">Instance of <see cref="INetworkManager"/> interface.</param> - /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param> - public SystemController( - IServerConfigurationManager serverConfigurationManager, - IServerApplicationHost appHost, - IFileSystem fileSystem, - INetworkManager network, - ILogger<SystemController> logger) - { - _appPaths = serverConfigurationManager.ApplicationPaths; - _appHost = appHost; - _fileSystem = fileSystem; - _network = network; - _logger = logger; - } + _appPaths = serverConfigurationManager.ApplicationPaths; + _appHost = appHost; + _fileSystem = fileSystem; + _network = network; + _logger = logger; + } - /// <summary> - /// Gets information about the server. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>A <see cref="SystemInfo"/> with info about the system.</returns> - [HttpGet("Info")] - [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<SystemInfo> GetSystemInfo() - { - return _appHost.GetSystemInfo(Request); - } + /// <summary> + /// Gets information about the server. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>A <see cref="SystemInfo"/> with info about the system.</returns> + [HttpGet("Info")] + [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<SystemInfo> GetSystemInfo() + { + return _appHost.GetSystemInfo(Request); + } - /// <summary> - /// Gets public information about the server. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns> - [HttpGet("Info/Public")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<PublicSystemInfo> GetPublicSystemInfo() - { - return _appHost.GetPublicSystemInfo(Request); - } + /// <summary> + /// Gets public information about the server. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns> + [HttpGet("Info/Public")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<PublicSystemInfo> GetPublicSystemInfo() + { + return _appHost.GetPublicSystemInfo(Request); + } - /// <summary> - /// Pings the system. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>The server name.</returns> - [HttpGet("Ping", Name = "GetPingSystem")] - [HttpPost("Ping", Name = "PostPingSystem")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<string> PingSystem() - { - return _appHost.Name; - } + /// <summary> + /// Pings the system. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>The server name.</returns> + [HttpGet("Ping", Name = "GetPingSystem")] + [HttpPost("Ping", Name = "PostPingSystem")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<string> PingSystem() + { + return _appHost.Name; + } - /// <summary> - /// Restarts the application. - /// </summary> - /// <response code="204">Server restarted.</response> - /// <returns>No content. Server restarted.</returns> - [HttpPost("Restart")] - [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult RestartApplication() + /// <summary> + /// Restarts the application. + /// </summary> + /// <response code="204">Server restarted.</response> + /// <returns>No content. Server restarted.</returns> + [HttpPost("Restart")] + [Authorize(Policy = Policies.LocalAccessOrRequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RestartApplication() + { + Task.Run(async () => { - Task.Run(async () => - { - await Task.Delay(100).ConfigureAwait(false); - _appHost.Restart(); - }); - return NoContent(); - } + await Task.Delay(100).ConfigureAwait(false); + _appHost.Restart(); + }); + return NoContent(); + } - /// <summary> - /// Shuts down the application. - /// </summary> - /// <response code="204">Server shut down.</response> - /// <returns>No content. Server shut down.</returns> - [HttpPost("Shutdown")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult ShutdownApplication() + /// <summary> + /// Shuts down the application. + /// </summary> + /// <response code="204">Server shut down.</response> + /// <returns>No content. Server shut down.</returns> + [HttpPost("Shutdown")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult ShutdownApplication() + { + Task.Run(async () => { - Task.Run(async () => - { - await Task.Delay(100).ConfigureAwait(false); - await _appHost.Shutdown().ConfigureAwait(false); - }); - return NoContent(); - } + await Task.Delay(100).ConfigureAwait(false); + await _appHost.Shutdown().ConfigureAwait(false); + }); + return NoContent(); + } + + /// <summary> + /// Gets a list of available server log files. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>An array of <see cref="LogFile"/> with the available log files.</returns> + [HttpGet("Logs")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<LogFile[]> GetServerLogs() + { + IEnumerable<FileSystemMetadata> files; - /// <summary> - /// Gets a list of available server log files. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>An array of <see cref="LogFile"/> with the available log files.</returns> - [HttpGet("Logs")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<LogFile[]> GetServerLogs() + try { - IEnumerable<FileSystemMetadata> files; - - try - { - files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error getting logs"); - files = Enumerable.Empty<FileSystemMetadata>(); - } - - var result = files.Select(i => new LogFile - { - DateCreated = _fileSystem.GetCreationTimeUtc(i), - DateModified = _fileSystem.GetLastWriteTimeUtc(i), - Name = i.Name, - Size = i.Length - }) - .OrderByDescending(i => i.DateModified) - .ThenByDescending(i => i.DateCreated) - .ThenBy(i => i.Name) - .ToArray(); - - return result; + files = _fileSystem.GetFiles(_appPaths.LogDirectoryPath, new[] { ".txt", ".log" }, true, false); } - - /// <summary> - /// Gets information about the request endpoint. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns> - [HttpGet("Endpoint")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<EndPointInfo> GetEndpointInfo() + catch (IOException ex) { - return new EndPointInfo - { - IsLocal = HttpContext.IsLocal(), - IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()) - }; + _logger.LogError(ex, "Error getting logs"); + files = Enumerable.Empty<FileSystemMetadata>(); } - /// <summary> - /// Gets a log file. - /// </summary> - /// <param name="name">The name of the log file to get.</param> - /// <response code="200">Log file retrieved.</response> - /// <returns>The log file.</returns> - [HttpGet("Logs/Log")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesFile(MediaTypeNames.Text.Plain)] - public ActionResult GetLogFile([FromQuery, Required] string name) + var result = files.Select(i => new LogFile { - var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + DateCreated = _fileSystem.GetCreationTimeUtc(i), + DateModified = _fileSystem.GetLastWriteTimeUtc(i), + Name = i.Name, + Size = i.Length + }) + .OrderByDescending(i => i.DateModified) + .ThenByDescending(i => i.DateCreated) + .ThenBy(i => i.Name) + .ToArray(); - // 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, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - return File(stream, "text/plain; charset=utf-8"); - } + return result; + } - /// <summary> - /// Gets wake on lan information. - /// </summary> - /// <response code="200">Information retrieved.</response> - /// <returns>An <see cref="IEnumerable{WakeOnLanInfo}"/> with the WakeOnLan infos.</returns> - [HttpGet("WakeOnLanInfo")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [Obsolete("This endpoint is obsolete.")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo() + /// <summary> + /// Gets information about the request endpoint. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns><see cref="EndPointInfo"/> with information about the endpoint.</returns> + [HttpGet("Endpoint")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<EndPointInfo> GetEndpointInfo() + { + return new EndPointInfo { - var result = _network.GetMacAddresses() - .Select(i => new WakeOnLanInfo(i)); - return Ok(result); - } + IsLocal = HttpContext.IsLocal(), + IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()) + }; + } + + /// <summary> + /// Gets a log file. + /// </summary> + /// <param name="name">The name of the log file to get.</param> + /// <response code="200">Log file retrieved.</response> + /// <returns>The log file.</returns> + [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) + .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + + // 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, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + return File(stream, "text/plain; charset=utf-8"); + } + + /// <summary> + /// Gets wake on lan information. + /// </summary> + /// <response code="200">Information retrieved.</response> + /// <returns>An <see cref="IEnumerable{WakeOnLanInfo}"/> with the WakeOnLan infos.</returns> + [HttpGet("WakeOnLanInfo")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [Obsolete("This endpoint is obsolete.")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo() + { + var result = _network.GetMacAddresses() + .Select(i => new WakeOnLanInfo(i)); + return Ok(result); } } diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index e7c5a7125..d7304cf42 100644 --- a/Jellyfin.Api/Controllers/TimeSyncController.cs +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -3,32 +3,31 @@ using MediaBrowser.Model.SyncPlay; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The time sync controller. +/// </summary> +[Route("")] +public class TimeSyncController : BaseJellyfinApiController { /// <summary> - /// The time sync controller. + /// Gets the current UTC time. /// </summary> - [Route("")] - public class TimeSyncController : BaseJellyfinApiController + /// <response code="200">Time returned.</response> + /// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns> + [HttpGet("GetUtcTime")] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + public ActionResult<UtcTimeResponse> GetUtcTime() { - /// <summary> - /// Gets the current UTC time. - /// </summary> - /// <response code="200">Time returned.</response> - /// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns> - [HttpGet("GetUtcTime")] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public ActionResult<UtcTimeResponse> GetUtcTime() - { - // Important to keep the following line at the beginning - var requestReceptionTime = DateTime.UtcNow; + // Important to keep the following line at the beginning + var requestReceptionTime = DateTime.UtcNow; - // Important to keep the following line at the end - var responseTransmissionTime = DateTime.UtcNow; + // Important to keep the following line at the end + var responseTransmissionTime = DateTime.UtcNow; - // Implementing NTP on such a high level results in this useless - // information being sent. On the other hand it enables future additions. - return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime); - } + // Implementing NTP on such a high level results in this useless + // information being sent. On the other hand it enables future additions. + return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime); } } diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index 53a839e43..115efcd8f 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -1,5 +1,4 @@ using System; -using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; @@ -10,290 +9,289 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The trailers controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class TrailersController : BaseJellyfinApiController { + private readonly ItemsController _itemsController; + /// <summary> - /// The trailers controller. + /// Initializes a new instance of the <see cref="TrailersController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class TrailersController : BaseJellyfinApiController + /// <param name="itemsController">Instance of <see cref="ItemsController"/>.</param> + public TrailersController(ItemsController itemsController) { - private readonly ItemsController _itemsController; - - /// <summary> - /// Initializes a new instance of the <see cref="TrailersController"/> class. - /// </summary> - /// <param name="itemsController">Instance of <see cref="ItemsController"/>.</param> - public TrailersController(ItemsController itemsController) - { - _itemsController = itemsController; - } + _itemsController = itemsController; + } - /// <summary> - /// Finds movies and trailers similar to a given trailer. - /// </summary> - /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param> - /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> - /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> - /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> - /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> - /// <param name="hasTrailer">Optional filter by items with trailers.</param> - /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> - /// <param name="parentIndexNumber">Optional filter by parent index number.</param> - /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> - /// <param name="isHd">Optional filter by items that are HD or not.</param> - /// <param name="is4K">Optional filter by items that are 4K or not.</param> - /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> - /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> - /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> - /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> - /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> - /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> - /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> - /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> - /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> - /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> - /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> - /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> - /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> - /// <param name="isMovie">Optional filter for live tv movies.</param> - /// <param name="isSeries">Optional filter for live tv series.</param> - /// <param name="isNews">Optional filter for live tv news.</param> - /// <param name="isKids">Optional filter for live tv kids.</param> - /// <param name="isSports">Optional filter for live tv sports.</param> - /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> - /// <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="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> - /// <param name="searchTerm">Optional. Filter based on a search term.</param> - /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> - /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> - /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="isPlayed">Optional filter by items that are played, or not.</param> - /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> - /// <param name="enableUserData">Optional, include user data.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> - /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> - /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> - /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> - /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> - /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> - /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> - /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> - /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> - /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> - /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> - /// <param name="isLocked">Optional filter by items that are locked.</param> - /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> - /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> - /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> - /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> - /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> - /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> - /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> - /// <param name="is3D">Optional filter by items that are 3D, or not.</param> - /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> - /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> - /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> - /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> - /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> - /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> - /// <param name="enableImages">Optional, include image information in output.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetTrailers( - [FromQuery] Guid? userId, - [FromQuery] string? maxOfficialRating, - [FromQuery] bool? hasThemeSong, - [FromQuery] bool? hasThemeVideo, - [FromQuery] bool? hasSubtitles, - [FromQuery] bool? hasSpecialFeature, - [FromQuery] bool? hasTrailer, - [FromQuery] Guid? adjacentTo, - [FromQuery] int? parentIndexNumber, - [FromQuery] bool? hasParentalRating, - [FromQuery] bool? isHd, - [FromQuery] bool? is4K, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, - [FromQuery] bool? isMissing, - [FromQuery] bool? isUnaired, - [FromQuery] double? minCommunityRating, - [FromQuery] double? minCriticRating, - [FromQuery] DateTime? minPremiereDate, - [FromQuery] DateTime? minDateLastSaved, - [FromQuery] DateTime? minDateLastSavedForUser, - [FromQuery] DateTime? maxPremiereDate, - [FromQuery] bool? hasOverview, - [FromQuery] bool? hasImdbId, - [FromQuery] bool? hasTmdbId, - [FromQuery] bool? hasTvdbId, - [FromQuery] bool? isMovie, - [FromQuery] bool? isSeries, - [FromQuery] bool? isNews, - [FromQuery] bool? isKids, - [FromQuery] bool? isSports, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? recursive, - [FromQuery] string? searchTerm, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, - [FromQuery] bool? isFavorite, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] bool? isPlayed, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, - [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? person, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, - [FromQuery] string? minOfficialRating, - [FromQuery] bool? isLocked, - [FromQuery] bool? isPlaceHolder, - [FromQuery] bool? hasOfficialRating, - [FromQuery] bool? collapseBoxSetItems, - [FromQuery] int? minWidth, - [FromQuery] int? minHeight, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] bool? is3D, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, - [FromQuery] string? nameStartsWithOrGreater, - [FromQuery] string? nameStartsWith, - [FromQuery] string? nameLessThan, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool? enableImages = true) - { - var includeItemTypes = new[] { BaseItemKind.Trailer }; + /// <summary> + /// Finds movies and trailers similar to a given trailer. + /// </summary> + /// <param name="userId">The user id supplied as query parameter; this is required when not using an API key.</param> + /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="hasThemeSong">Optional filter by items with theme songs.</param> + /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param> + /// <param name="hasSubtitles">Optional filter by items with subtitles.</param> + /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> + /// <param name="hasTrailer">Optional filter by items with trailers.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="parentIndexNumber">Optional filter by parent index number.</param> + /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> + /// <param name="isHd">Optional filter by items that are HD or not.</param> + /// <param name="is4K">Optional filter by items that are 4K or not.</param> + /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param> + /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param> + /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param> + /// <param name="minCommunityRating">Optional filter by minimum community rating.</param> + /// <param name="minCriticRating">Optional filter by minimum critic rating.</param> + /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param> + /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param> + /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> + /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> + /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> + /// <param name="isMovie">Optional filter for live tv movies.</param> + /// <param name="isSeries">Optional filter for live tv series.</param> + /// <param name="isNews">Optional filter for live tv news.</param> + /// <param name="isKids">Optional filter for live tv kids.</param> + /// <param name="isSports">Optional filter for live tv sports.</param> + /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param> + /// <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="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> + /// <param name="searchTerm">Optional. Filter based on a search term.</param> + /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param> + /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param> + /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="isPlayed">Optional filter by items that are played, or not.</param> + /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param> + /// <param name="enableUserData">Optional, include user data.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param> + /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param> + /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param> + /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param> + /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param> + /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param> + /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param> + /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param> + /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param> + /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param> + /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param> + /// <param name="isLocked">Optional filter by items that are locked.</param> + /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param> + /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param> + /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param> + /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param> + /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param> + /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param> + /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param> + /// <param name="is3D">Optional filter by items that are 3D, or not.</param> + /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param> + /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param> + /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param> + /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param> + /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param> + /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param> + /// <param name="enableImages">Optional, include image information in output.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetTrailers( + [FromQuery] Guid? userId, + [FromQuery] string? maxOfficialRating, + [FromQuery] bool? hasThemeSong, + [FromQuery] bool? hasThemeVideo, + [FromQuery] bool? hasSubtitles, + [FromQuery] bool? hasSpecialFeature, + [FromQuery] bool? hasTrailer, + [FromQuery] Guid? adjacentTo, + [FromQuery] int? parentIndexNumber, + [FromQuery] bool? hasParentalRating, + [FromQuery] bool? isHd, + [FromQuery] bool? is4K, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, + [FromQuery] bool? isMissing, + [FromQuery] bool? isUnaired, + [FromQuery] double? minCommunityRating, + [FromQuery] double? minCriticRating, + [FromQuery] DateTime? minPremiereDate, + [FromQuery] DateTime? minDateLastSaved, + [FromQuery] DateTime? minDateLastSavedForUser, + [FromQuery] DateTime? maxPremiereDate, + [FromQuery] bool? hasOverview, + [FromQuery] bool? hasImdbId, + [FromQuery] bool? hasTmdbId, + [FromQuery] bool? hasTvdbId, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? recursive, + [FromQuery] string? searchTerm, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery] bool? isPlayed, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] string? person, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes, + [FromQuery] string? minOfficialRating, + [FromQuery] bool? isLocked, + [FromQuery] bool? isPlaceHolder, + [FromQuery] bool? hasOfficialRating, + [FromQuery] bool? collapseBoxSetItems, + [FromQuery] int? minWidth, + [FromQuery] int? minHeight, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] bool? is3D, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, + [FromQuery] string? nameStartsWithOrGreater, + [FromQuery] string? nameStartsWith, + [FromQuery] string? nameLessThan, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool? enableImages = true) + { + var includeItemTypes = new[] { BaseItemKind.Trailer }; - return _itemsController - .GetItems( - userId, - maxOfficialRating, - hasThemeSong, - hasThemeVideo, - hasSubtitles, - hasSpecialFeature, - hasTrailer, - adjacentTo, - parentIndexNumber, - hasParentalRating, - isHd, - is4K, - locationTypes, - excludeLocationTypes, - isMissing, - isUnaired, - minCommunityRating, - minCriticRating, - minPremiereDate, - minDateLastSaved, - minDateLastSavedForUser, - maxPremiereDate, - hasOverview, - hasImdbId, - hasTmdbId, - hasTvdbId, - isMovie, - isSeries, - isNews, - isKids, - isSports, - excludeItemIds, - startIndex, - limit, - recursive, - searchTerm, - sortOrder, - parentId, - fields, - excludeItemTypes, - includeItemTypes, - filters, - isFavorite, - mediaTypes, - imageTypes, - sortBy, - isPlayed, - genres, - officialRatings, - tags, - years, - enableUserData, - imageTypeLimit, - enableImageTypes, - person, - personIds, - personTypes, - studios, - artists, - excludeArtistIds, - artistIds, - albumArtistIds, - contributingArtistIds, - albums, - albumIds, - ids, - videoTypes, - minOfficialRating, - isLocked, - isPlaceHolder, - hasOfficialRating, - collapseBoxSetItems, - minWidth, - minHeight, - maxWidth, - maxHeight, - is3D, - seriesStatus, - nameStartsWithOrGreater, - nameStartsWith, - nameLessThan, - studioIds, - genreIds, - enableTotalRecordCount, - enableImages); - } + return _itemsController + .GetItems( + userId, + maxOfficialRating, + hasThemeSong, + hasThemeVideo, + hasSubtitles, + hasSpecialFeature, + hasTrailer, + adjacentTo, + parentIndexNumber, + hasParentalRating, + isHd, + is4K, + locationTypes, + excludeLocationTypes, + isMissing, + isUnaired, + minCommunityRating, + minCriticRating, + minPremiereDate, + minDateLastSaved, + minDateLastSavedForUser, + maxPremiereDate, + hasOverview, + hasImdbId, + hasTmdbId, + hasTvdbId, + isMovie, + isSeries, + isNews, + isKids, + isSports, + excludeItemIds, + startIndex, + limit, + recursive, + searchTerm, + sortOrder, + parentId, + fields, + excludeItemTypes, + includeItemTypes, + filters, + isFavorite, + mediaTypes, + imageTypes, + sortBy, + isPlayed, + genres, + officialRatings, + tags, + years, + enableUserData, + imageTypeLimit, + enableImageTypes, + person, + personIds, + personTypes, + studios, + artists, + excludeArtistIds, + artistIds, + albumArtistIds, + contributingArtistIds, + albums, + albumIds, + ids, + videoTypes, + minOfficialRating, + isLocked, + isPlaceHolder, + hasOfficialRating, + collapseBoxSetItems, + minWidth, + minHeight, + maxWidth, + maxHeight, + is3D, + seriesStatus, + nameStartsWithOrGreater, + nameStartsWith, + nameLessThan, + studioIds, + genreIds, + enableTotalRecordCount, + enableImages); } } diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 7f4f4d077..2be32095e 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -19,366 +19,365 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The tv shows controller. +/// </summary> +[Route("Shows")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class TvShowsController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly ITVSeriesManager _tvSeriesManager; + /// <summary> - /// The tv shows controller. + /// Initializes a new instance of the <see cref="TvShowsController"/> class. /// </summary> - [Route("Shows")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class TvShowsController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="tvSeriesManager">Instance of the <see cref="ITVSeriesManager"/> interface.</param> + public TvShowsController( + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService, + ITVSeriesManager tvSeriesManager) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly ITVSeriesManager _tvSeriesManager; - - /// <summary> - /// Initializes a new instance of the <see cref="TvShowsController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="tvSeriesManager">Instance of the <see cref="ITVSeriesManager"/> interface.</param> - public TvShowsController( - IUserManager userManager, - ILibraryManager libraryManager, - IDtoService dtoService, - ITVSeriesManager tvSeriesManager) - { - _userManager = userManager; - _libraryManager = libraryManager; - _dtoService = dtoService; - _tvSeriesManager = tvSeriesManager; - } - - /// <summary> - /// Gets a list of next up episodes. - /// </summary> - /// <param name="userId">The user id of the user to get the next up episodes for.</param> - /// <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="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="seriesId">Optional. Filter by series id.</param> - /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param> - /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param> - /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param> - /// <param name="enableRewatching">Whether to include watched episode in next up results.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> - [HttpGet("NextUp")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetNextUp( - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] Guid? seriesId, - [FromQuery] Guid? parentId, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData, - [FromQuery] DateTime? nextUpDateCutoff, - [FromQuery] bool enableTotalRecordCount = true, - [FromQuery] bool disableFirstEpisode = false, - [FromQuery] bool enableRewatching = false) - { - var options = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - - var result = _tvSeriesManager.GetNextUp( - new NextUpQuery - { - Limit = limit, - ParentId = parentId, - SeriesId = seriesId, - StartIndex = startIndex, - UserId = userId ?? Guid.Empty, - EnableTotalRecordCount = enableTotalRecordCount, - DisableFirstEpisode = disableFirstEpisode, - NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue, - EnableRewatching = enableRewatching - }, - options); - - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user); - - return new QueryResult<BaseItemDto>( - startIndex, - result.TotalRecordCount, - returnItems); - } - - /// <summary> - /// Gets a list of upcoming episodes. - /// </summary> - /// <param name="userId">The user id of the user to get the upcoming episodes for.</param> - /// <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="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> - [HttpGet("Upcoming")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes( - [FromQuery] Guid? userId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] Guid? parentId, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var minPremiereDate = DateTime.UtcNow.Date.AddDays(-1); - - var parentIdGuid = parentId ?? Guid.Empty; + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + _tvSeriesManager = tvSeriesManager; + } - var options = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + /// <summary> + /// Gets a list of next up episodes. + /// </summary> + /// <param name="userId">The user id of the user to get the next up episodes for.</param> + /// <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="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="seriesId">Optional. Filter by series id.</param> + /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param> + /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param> + /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param> + /// <param name="enableRewatching">Whether to include watched episode in next up results.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> + [HttpGet("NextUp")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetNextUp( + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] Guid? seriesId, + [FromQuery] Guid? parentId, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] DateTime? nextUpDateCutoff, + [FromQuery] bool enableTotalRecordCount = true, + [FromQuery] bool disableFirstEpisode = false, + [FromQuery] bool enableRewatching = false) + { + var options = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) + var result = _tvSeriesManager.GetNextUp( + new NextUpQuery { - IncludeItemTypes = new[] { BaseItemKind.Episode }, - OrderBy = new[] { (ItemSortBy.PremiereDate, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, - MinPremiereDate = minPremiereDate, - StartIndex = startIndex, Limit = limit, - ParentId = parentIdGuid, - Recursive = true, - DtoOptions = options - }); + ParentId = parentId, + SeriesId = seriesId, + StartIndex = startIndex, + UserId = userId ?? Guid.Empty, + EnableTotalRecordCount = enableTotalRecordCount, + DisableFirstEpisode = disableFirstEpisode, + NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue, + EnableRewatching = enableRewatching + }, + options); + + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user); + + return new QueryResult<BaseItemDto>( + startIndex, + result.TotalRecordCount, + returnItems); + } - var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user); + /// <summary> + /// Gets a list of upcoming episodes. + /// </summary> + /// <param name="userId">The user id of the user to get the upcoming episodes for.</param> + /// <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="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="parentId">Optional. Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> + [HttpGet("Upcoming")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes( + [FromQuery] Guid? userId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] Guid? parentId, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - return new QueryResult<BaseItemDto>( - startIndex, - itemsResult.Count, - returnItems); - } + var minPremiereDate = DateTime.UtcNow.Date.AddDays(-1); - /// <summary> - /// Gets episodes for a tv season. - /// </summary> - /// <param name="seriesId">The series id.</param> - /// <param name="userId">The user id.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> - /// <param name="season">Optional filter by season number.</param> - /// <param name="seasonId">Optional. Filter by season id.</param> - /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> - /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> - /// <param name="startItemId">Optional. Skip through the list until a given item is found.</param> - /// <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="enableImages">Optional, include image information in output.</param> - /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> - [HttpGet("{seriesId}/Episodes")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<QueryResult<BaseItemDto>> GetEpisodes( - [FromRoute, Required] Guid seriesId, - [FromQuery] Guid? userId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] int? season, - [FromQuery] Guid? seasonId, - [FromQuery] bool? isMissing, - [FromQuery] Guid? adjacentTo, - [FromQuery] Guid? startItemId, - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData, - [FromQuery] string? sortBy) - { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + var parentIdGuid = parentId ?? Guid.Empty; - List<BaseItem> episodes; + var options = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { BaseItemKind.Episode }, + OrderBy = new[] { (ItemSortBy.PremiereDate, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) }, + MinPremiereDate = minPremiereDate, + StartIndex = startIndex, + Limit = limit, + ParentId = parentIdGuid, + Recursive = true, + DtoOptions = options + }); + + var returnItems = _dtoService.GetBaseItemDtos(itemsResult, options, user); + + return new QueryResult<BaseItemDto>( + startIndex, + itemsResult.Count, + returnItems); + } - if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. - { - var item = _libraryManager.GetItemById(seasonId.Value); - if (item is not Season seasonItem) - { - return NotFound("No season exists with Id " + seasonId); - } + /// <summary> + /// Gets episodes for a tv season. + /// </summary> + /// <param name="seriesId">The series id.</param> + /// <param name="userId">The user id.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="season">Optional filter by season number.</param> + /// <param name="seasonId">Optional. Filter by season id.</param> + /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="startItemId">Optional. Skip through the list until a given item is found.</param> + /// <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="enableImages">Optional, include image information in output.</param> + /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> + [HttpGet("{seriesId}/Episodes")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QueryResult<BaseItemDto>> GetEpisodes( + [FromRoute, Required] Guid seriesId, + [FromQuery] Guid? userId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] int? season, + [FromQuery] Guid? seasonId, + [FromQuery] bool? isMissing, + [FromQuery] Guid? adjacentTo, + [FromQuery] Guid? startItemId, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] string? sortBy) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - episodes = seasonItem.GetEpisodes(user, dtoOptions); - } - else if (season.HasValue) // Season number was supplied. Get episodes by season number - { - if (_libraryManager.GetItemById(seriesId) is not Series series) - { - return NotFound("Series not found"); - } - - var seasonItem = series - .GetSeasons(user, dtoOptions) - .FirstOrDefault(i => i.IndexNumber == season.Value); - - episodes = seasonItem is null ? - new List<BaseItem>() - : ((Season)seasonItem).GetEpisodes(user, dtoOptions); - } - else // No season number or season id was supplied. Returning all episodes. - { - if (_libraryManager.GetItemById(seriesId) is not Series series) - { - return NotFound("Series not found"); - } + List<BaseItem> episodes; - episodes = series.GetEpisodes(user, dtoOptions).ToList(); - } + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - // Filter after the fact in case the ui doesn't want them - if (isMissing.HasValue) + if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. + { + var item = _libraryManager.GetItemById(seasonId.Value); + if (item is not Season seasonItem) { - var val = isMissing.Value; - episodes = episodes - .Where(i => ((Episode)i).IsMissingEpisode == val) - .ToList(); + return NotFound("No season exists with Id " + seasonId); } - if (startItemId.HasValue) + episodes = seasonItem.GetEpisodes(user, dtoOptions); + } + else if (season.HasValue) // Season number was supplied. Get episodes by season number + { + if (_libraryManager.GetItemById(seriesId) is not Series series) { - episodes = episodes - .SkipWhile(i => !startItemId.Value.Equals(i.Id)) - .ToList(); + return NotFound("Series not found"); } - // This must be the last filter - if (adjacentTo.HasValue && !adjacentTo.Value.Equals(default)) - { - episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo.Value).ToList(); - } + var seasonItem = series + .GetSeasons(user, dtoOptions) + .FirstOrDefault(i => i.IndexNumber == season.Value); - if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase)) + episodes = seasonItem is null ? + new List<BaseItem>() + : ((Season)seasonItem).GetEpisodes(user, dtoOptions); + } + else // No season number or season id was supplied. Returning all episodes. + { + if (_libraryManager.GetItemById(seriesId) is not Series series) { - episodes.Shuffle(); + return NotFound("Series not found"); } - var returnItems = episodes; + episodes = series.GetEpisodes(user, dtoOptions).ToList(); + } - if (startIndex.HasValue || limit.HasValue) - { - returnItems = ApplyPaging(episodes, startIndex, limit).ToList(); - } + // Filter after the fact in case the ui doesn't want them + if (isMissing.HasValue) + { + var val = isMissing.Value; + episodes = episodes + .Where(i => ((Episode)i).IsMissingEpisode == val) + .ToList(); + } - var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); + if (startItemId.HasValue) + { + episodes = episodes + .SkipWhile(i => !startItemId.Value.Equals(i.Id)) + .ToList(); + } - return new QueryResult<BaseItemDto>( - startIndex, - episodes.Count, - dtos); + // This must be the last filter + if (adjacentTo.HasValue && !adjacentTo.Value.Equals(default)) + { + episodes = UserViewBuilder.FilterForAdjacency(episodes, adjacentTo.Value).ToList(); } - /// <summary> - /// Gets seasons for a tv series. - /// </summary> - /// <param name="seriesId">The series id.</param> - /// <param name="userId">The user id.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> - /// <param name="isSpecialSeason">Optional. Filter by special season.</param> - /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> - /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> - [HttpGet("{seriesId}/Seasons")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<QueryResult<BaseItemDto>> GetSeasons( - [FromRoute, Required] Guid seriesId, - [FromQuery] Guid? userId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] bool? isSpecialSeason, - [FromQuery] bool? isMissing, - [FromQuery] Guid? adjacentTo, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData) + if (string.Equals(sortBy, ItemSortBy.Random, StringComparison.OrdinalIgnoreCase)) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); + episodes.Shuffle(); + } - if (_libraryManager.GetItemById(seriesId) is not Series series) - { - return NotFound("Series not found"); - } + var returnItems = episodes; - var seasons = series.GetItemList(new InternalItemsQuery(user) - { - IsMissing = isMissing, - IsSpecialSeason = isSpecialSeason, - AdjacentTo = adjacentTo - }); + if (startIndex.HasValue || limit.HasValue) + { + returnItems = ApplyPaging(episodes, startIndex, limit).ToList(); + } + + var dtos = _dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + return new QueryResult<BaseItemDto>( + startIndex, + episodes.Count, + dtos); + } - var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user); + /// <summary> + /// Gets seasons for a tv series. + /// </summary> + /// <param name="seriesId">The series id.</param> + /// <param name="userId">The user id.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param> + /// <param name="isSpecialSeason">Optional. Filter by special season.</param> + /// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param> + /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> on success or a <see cref="NotFoundResult"/> if the series was not found.</returns> + [HttpGet("{seriesId}/Seasons")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<QueryResult<BaseItemDto>> GetSeasons( + [FromRoute, Required] Guid seriesId, + [FromQuery] Guid? userId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery] bool? isSpecialSeason, + [FromQuery] bool? isMissing, + [FromQuery] Guid? adjacentTo, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); - return new QueryResult<BaseItemDto>(returnItems); + if (_libraryManager.GetItemById(seriesId) is not Series series) + { + return NotFound("Series not found"); } - /// <summary> - /// Applies the paging. - /// </summary> - /// <param name="items">The items.</param> - /// <param name="startIndex">The start index.</param> - /// <param name="limit">The limit.</param> - /// <returns>IEnumerable{BaseItem}.</returns> - private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit) + var seasons = series.GetItemList(new InternalItemsQuery(user) { - // Start at - if (startIndex.HasValue) - { - items = items.Skip(startIndex.Value); - } + IsMissing = isMissing, + IsSpecialSeason = isSpecialSeason, + AdjacentTo = adjacentTo + }); - // Return limit - if (limit.HasValue) - { - items = items.Take(limit.Value); - } + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + + var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user); - return items; + return new QueryResult<BaseItemDto>(returnItems); + } + + /// <summary> + /// Applies the paging. + /// </summary> + /// <param name="items">The items.</param> + /// <param name="startIndex">The start index.</param> + /// <param name="limit">The limit.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + private IEnumerable<BaseItem> ApplyPaging(IEnumerable<BaseItem> items, int? startIndex, int? limit) + { + // Start at + if (startIndex.HasValue) + { + items = items.Skip(startIndex.Value); } + + // Return limit + if (limit.HasValue) + { + items = items.Take(limit.Value); + } + + return items; } } diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index d77126a35..6946caa2b 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -20,197 +20,164 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The universal audio controller. +/// </summary> +[Route("")] +public class UniversalAudioController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly ILogger<UniversalAudioController> _logger; + private readonly MediaInfoHelper _mediaInfoHelper; + private readonly AudioHelper _audioHelper; + private readonly DynamicHlsHelper _dynamicHlsHelper; + /// <summary> - /// The universal audio controller. + /// Initializes a new instance of the <see cref="UniversalAudioController"/> class. /// </summary> - [Route("")] - public class UniversalAudioController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{UniversalAudioController}"/> interface.</param> + /// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param> + /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param> + /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> + public UniversalAudioController( + ILibraryManager libraryManager, + ILogger<UniversalAudioController> logger, + MediaInfoHelper mediaInfoHelper, + AudioHelper audioHelper, + DynamicHlsHelper dynamicHlsHelper) { - private readonly ILibraryManager _libraryManager; - private readonly ILogger<UniversalAudioController> _logger; - private readonly MediaInfoHelper _mediaInfoHelper; - private readonly AudioHelper _audioHelper; - private readonly DynamicHlsHelper _dynamicHlsHelper; + _libraryManager = libraryManager; + _logger = logger; + _mediaInfoHelper = mediaInfoHelper; + _audioHelper = audioHelper; + _dynamicHlsHelper = dynamicHlsHelper; + } - /// <summary> - /// Initializes a new instance of the <see cref="UniversalAudioController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{UniversalAudioController}"/> interface.</param> - /// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param> - /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param> - /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> - public UniversalAudioController( - ILibraryManager libraryManager, - ILogger<UniversalAudioController> logger, - MediaInfoHelper mediaInfoHelper, - AudioHelper audioHelper, - DynamicHlsHelper dynamicHlsHelper) - { - _libraryManager = libraryManager; - _logger = logger; - _mediaInfoHelper = mediaInfoHelper; - _audioHelper = audioHelper; - _dynamicHlsHelper = dynamicHlsHelper; - } + /// <summary> + /// Gets an audio stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">Optional. The audio container.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="userId">Optional. The user id.</param> + /// <param name="audioCodec">Optional. The audio codec to transcode to.</param> + /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param> + /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param> + /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="transcodingContainer">Optional. The container to transcode to.</param> + /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param> + /// <param name="maxAudioSampleRate">Optional. The maximum audio sample rate.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param> + /// <response code="200">Audio stream returned.</response> + /// <response code="302">Redirected to remote audio stream.</response> + /// <returns>A <see cref="Task"/> containing the audio file.</returns> + [HttpGet("Audio/{itemId}/universal")] + [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesAudioFile] + public async Task<ActionResult> GetUniversalAudioStream( + [FromRoute, Required] Guid itemId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] Guid? userId, + [FromQuery] string? audioCodec, + [FromQuery] int? maxAudioChannels, + [FromQuery] int? transcodingAudioChannels, + [FromQuery] int? maxStreamingBitrate, + [FromQuery] int? audioBitRate, + [FromQuery] long? startTimeTicks, + [FromQuery] string? transcodingContainer, + [FromQuery] string? transcodingProtocol, + [FromQuery] int? maxAudioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] bool? enableRemoteMedia, + [FromQuery] bool breakOnNonKeyFrames = false, + [FromQuery] bool enableRedirection = true) + { + var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); - /// <summary> - /// Gets an audio stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">Optional. The audio container.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="userId">Optional. The user id.</param> - /// <param name="audioCodec">Optional. The audio codec to transcode to.</param> - /// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param> - /// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param> - /// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="transcodingContainer">Optional. The container to transcode to.</param> - /// <param name="transcodingProtocol">Optional. The transcoding protocol.</param> - /// <param name="maxAudioSampleRate">Optional. The maximum audio sample rate.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param> - /// <response code="200">Audio stream returned.</response> - /// <response code="302">Redirected to remote audio stream.</response> - /// <returns>A <see cref="Task"/> containing the audio file.</returns> - [HttpGet("Audio/{itemId}/universal")] - [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status302Found)] - [ProducesAudioFile] - public async Task<ActionResult> GetUniversalAudioStream( - [FromRoute, Required] Guid itemId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] Guid? userId, - [FromQuery] string? audioCodec, - [FromQuery] int? maxAudioChannels, - [FromQuery] int? transcodingAudioChannels, - [FromQuery] int? maxStreamingBitrate, - [FromQuery] int? audioBitRate, - [FromQuery] long? startTimeTicks, - [FromQuery] string? transcodingContainer, - [FromQuery] string? transcodingProtocol, - [FromQuery] int? maxAudioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] bool? enableRemoteMedia, - [FromQuery] bool breakOnNonKeyFrames = false, - [FromQuery] bool enableRedirection = true) + if (!userId.HasValue || userId.Value.Equals(default)) { - var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); - - if (!userId.HasValue || userId.Value.Equals(default)) - { - userId = User.GetUserId(); - } - - _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); - - var info = await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId, - mediaSourceId) - .ConfigureAwait(false); + userId = User.GetUserId(); + } - // set device specific data - var item = _libraryManager.GetItemById(itemId); + _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); - foreach (var sourceInfo in info.MediaSources) - { - _mediaInfoHelper.SetDeviceSpecificData( - item, - sourceInfo, - deviceProfile, - User, - maxStreamingBitrate ?? deviceProfile.MaxStreamingBitrate, - startTimeTicks ?? 0, - mediaSourceId ?? string.Empty, - null, - null, - maxAudioChannels, - info.PlaySessionId!, - userId ?? Guid.Empty, - true, - true, - true, - true, - true, - Request.HttpContext.GetNormalizedRemoteIp()); - } + var info = await _mediaInfoHelper.GetPlaybackInfo( + itemId, + userId, + mediaSourceId) + .ConfigureAwait(false); - _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); + // set device specific data + var item = _libraryManager.GetItemById(itemId); - foreach (var source in info.MediaSources) - { - _mediaInfoHelper.NormalizeMediaSourceContainer(source, deviceProfile, DlnaProfileType.Video); - } + foreach (var sourceInfo in info.MediaSources) + { + _mediaInfoHelper.SetDeviceSpecificData( + item, + sourceInfo, + deviceProfile, + User, + maxStreamingBitrate ?? deviceProfile.MaxStreamingBitrate, + startTimeTicks ?? 0, + mediaSourceId ?? string.Empty, + null, + null, + maxAudioChannels, + info.PlaySessionId!, + userId ?? Guid.Empty, + true, + true, + true, + true, + true, + Request.HttpContext.GetNormalizedRemoteIp()); + } - var mediaSource = info.MediaSources[0]; - if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http && enableRedirection && mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value) - { - return Redirect(mediaSource.Path); - } + _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); - var isStatic = mediaSource.SupportsDirectStream; - if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) - { - // hls segment container can only be mpegts or fmp4 per ffmpeg documentation - // ffmpeg option -> file extension - // mpegts -> ts - // fmp4 -> mp4 - // TODO: remove this when we switch back to the segment muxer - var supportedHlsContainers = new[] { "ts", "mp4" }; + foreach (var source in info.MediaSources) + { + _mediaInfoHelper.NormalizeMediaSourceContainer(source, deviceProfile, DlnaProfileType.Video); + } - var dynamicHlsRequestDto = new HlsAudioRequestDto - { - Id = itemId, - Container = ".m3u8", - Static = isStatic, - PlaySessionId = info.PlaySessionId, - // fallback to mpegts if device reports some weird value unsupported by hls - SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts", - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = true, - AllowAudioStreamCopy = true, - AllowVideoStreamCopy = true, - BreakOnNonKeyFrames = breakOnNonKeyFrames, - AudioSampleRate = maxAudioSampleRate, - MaxAudioChannels = maxAudioChannels, - MaxAudioBitDepth = maxAudioBitDepth, - AudioBitRate = audioBitRate ?? maxStreamingBitrate, - StartTimeTicks = startTimeTicks, - SubtitleMethod = SubtitleDeliveryMethod.Hls, - RequireAvc = false, - DeInterlace = false, - RequireNonAnamorphic = false, - EnableMpegtsM2TsMode = false, - TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(), - Context = EncodingContext.Static, - StreamOptions = new Dictionary<string, string>(), - EnableAdaptiveBitrateStreaming = true - }; + var mediaSource = info.MediaSources[0]; + if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http && enableRedirection && mediaSource.IsRemote && enableRemoteMedia.HasValue && enableRemoteMedia.Value) + { + return Redirect(mediaSource.Path); + } - return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true) - .ConfigureAwait(false); - } + var isStatic = mediaSource.SupportsDirectStream; + if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) + { + // hls segment container can only be mpegts or fmp4 per ffmpeg documentation + // ffmpeg option -> file extension + // mpegts -> ts + // fmp4 -> mp4 + // TODO: remove this when we switch back to the segment muxer + var supportedHlsContainers = new[] { "ts", "mp4" }; - var audioStreamingDto = new StreamingRequestDto + var dynamicHlsRequestDto = new HlsAudioRequestDto { Id = itemId, - Container = isStatic ? null : ("." + mediaSource.TranscodingContainer), + Container = ".m3u8", Static = isStatic, PlaySessionId = info.PlaySessionId, + // fallback to mpegts if device reports some weird value unsupported by hls + SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts", MediaSourceId = mediaSourceId, DeviceId = deviceId, AudioCodec = audioCodec, @@ -220,121 +187,153 @@ namespace Jellyfin.Api.Controllers BreakOnNonKeyFrames = breakOnNonKeyFrames, AudioSampleRate = maxAudioSampleRate, MaxAudioChannels = maxAudioChannels, - AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate), MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = maxAudioChannels, - CopyTimestamps = true, + AudioBitRate = audioBitRate ?? maxStreamingBitrate, StartTimeTicks = startTimeTicks, - SubtitleMethod = SubtitleDeliveryMethod.Embed, + SubtitleMethod = SubtitleDeliveryMethod.Hls, + RequireAvc = false, + DeInterlace = false, + RequireNonAnamorphic = false, + EnableMpegtsM2TsMode = false, TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(), - Context = EncodingContext.Static + Context = EncodingContext.Static, + StreamOptions = new Dictionary<string, string>(), + EnableAdaptiveBitrateStreaming = true }; - return await _audioHelper.GetAudioStream(TranscodingJobType.Progressive, audioStreamingDto).ConfigureAwait(false); + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true) + .ConfigureAwait(false); } - private DeviceProfile GetDeviceProfile( - string[] containers, - string? transcodingContainer, - string? audioCodec, - string? transcodingProtocol, - bool? breakOnNonKeyFrames, - int? transcodingAudioChannels, - int? maxAudioSampleRate, - int? maxAudioBitDepth, - int? maxAudioChannels) + var audioStreamingDto = new StreamingRequestDto { - var deviceProfile = new DeviceProfile(); + Id = itemId, + Container = isStatic ? null : ("." + mediaSource.TranscodingContainer), + Static = isStatic, + PlaySessionId = info.PlaySessionId, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = true, + AllowAudioStreamCopy = true, + AllowVideoStreamCopy = true, + BreakOnNonKeyFrames = breakOnNonKeyFrames, + AudioSampleRate = maxAudioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate), + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = maxAudioChannels, + CopyTimestamps = true, + StartTimeTicks = startTimeTicks, + SubtitleMethod = SubtitleDeliveryMethod.Embed, + TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(), + Context = EncodingContext.Static + }; - int len = containers.Length; - var directPlayProfiles = new DirectPlayProfile[len]; - for (int i = 0; i < len; i++) - { - var parts = containers[i].Split('|', StringSplitOptions.RemoveEmptyEntries); + return await _audioHelper.GetAudioStream(TranscodingJobType.Progressive, audioStreamingDto).ConfigureAwait(false); + } - var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1)); + private DeviceProfile GetDeviceProfile( + string[] containers, + string? transcodingContainer, + string? audioCodec, + string? transcodingProtocol, + bool? breakOnNonKeyFrames, + int? transcodingAudioChannels, + int? maxAudioSampleRate, + int? maxAudioBitDepth, + int? maxAudioChannels) + { + var deviceProfile = new DeviceProfile(); - directPlayProfiles[i] = new DirectPlayProfile - { - Type = DlnaProfileType.Audio, - Container = parts[0], - AudioCodec = audioCodecs - }; - } + int len = containers.Length; + var directPlayProfiles = new DirectPlayProfile[len]; + for (int i = 0; i < len; i++) + { + var parts = containers[i].Split('|', StringSplitOptions.RemoveEmptyEntries); - deviceProfile.DirectPlayProfiles = directPlayProfiles; + var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1)); - deviceProfile.TranscodingProfiles = new[] + directPlayProfiles[i] = new DirectPlayProfile { - new TranscodingProfile - { - Type = DlnaProfileType.Audio, - Context = EncodingContext.Streaming, - Container = transcodingContainer ?? "mp3", - AudioCodec = audioCodec ?? "mp3", - Protocol = transcodingProtocol ?? "http", - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture) - } + Type = DlnaProfileType.Audio, + Container = parts[0], + AudioCodec = audioCodecs }; + } - var codecProfiles = new List<CodecProfile>(); - var conditions = new List<ProfileCondition>(); + deviceProfile.DirectPlayProfiles = directPlayProfiles; - if (maxAudioSampleRate.HasValue) + deviceProfile.TranscodingProfiles = new[] + { + new TranscodingProfile { - // codec profile - conditions.Add( - new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioSampleRate, - Value = maxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) - }); + Type = DlnaProfileType.Audio, + Context = EncodingContext.Streaming, + Container = transcodingContainer ?? "mp3", + AudioCodec = audioCodec ?? "mp3", + Protocol = transcodingProtocol ?? "http", + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture) } + }; - if (maxAudioBitDepth.HasValue) - { - // codec profile - conditions.Add( - new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioBitDepth, - Value = maxAudioBitDepth.Value.ToString(CultureInfo.InvariantCulture) - }); - } + var codecProfiles = new List<CodecProfile>(); + var conditions = new List<ProfileCondition>(); - if (maxAudioChannels.HasValue) - { - // codec profile - conditions.Add( - new ProfileCondition - { - Condition = ProfileConditionType.LessThanEqual, - IsRequired = false, - Property = ProfileConditionValue.AudioChannels, - Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) - }); - } + if (maxAudioSampleRate.HasValue) + { + // codec profile + conditions.Add( + new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioSampleRate, + Value = maxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) + }); + } - if (conditions.Count > 0) - { - // codec profile - codecProfiles.Add( - new CodecProfile - { - Type = CodecType.Audio, - Container = string.Join(',', containers), - Conditions = conditions.ToArray() - }); - } + if (maxAudioBitDepth.HasValue) + { + // codec profile + conditions.Add( + new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioBitDepth, + Value = maxAudioBitDepth.Value.ToString(CultureInfo.InvariantCulture) + }); + } - deviceProfile.CodecProfiles = codecProfiles.ToArray(); + if (maxAudioChannels.HasValue) + { + // codec profile + conditions.Add( + new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioChannels, + Value = maxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) + }); + } - return deviceProfile; + if (conditions.Count > 0) + { + // codec profile + codecProfiles.Add( + new CodecProfile + { + Type = CodecType.Audio, + Container = string.Join(',', containers), + Conditions = conditions.ToArray() + }); } + + deviceProfile.CodecProfiles = codecProfiles.ToArray(); + + return deviceProfile; } } diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 06f2227b8..7f184f31e 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -25,564 +25,563 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// User controller. +/// </summary> +[Route("Users")] +public class UserController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly ISessionManager _sessionManager; + private readonly INetworkManager _networkManager; + private readonly IDeviceManager _deviceManager; + private readonly IAuthorizationContext _authContext; + private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; + private readonly IQuickConnect _quickConnectManager; + + /// <summary> + /// Initializes a new instance of the <see cref="UserController"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="quickConnectManager">Instance of the <see cref="IQuickConnect"/> interface.</param> + public UserController( + IUserManager userManager, + ISessionManager sessionManager, + INetworkManager networkManager, + IDeviceManager deviceManager, + IAuthorizationContext authContext, + IServerConfigurationManager config, + ILogger<UserController> logger, + IQuickConnect quickConnectManager) + { + _userManager = userManager; + _sessionManager = sessionManager; + _networkManager = networkManager; + _deviceManager = deviceManager; + _authContext = authContext; + _config = config; + _logger = logger; + _quickConnectManager = quickConnectManager; + } + + /// <summary> + /// Gets a list of users. + /// </summary> + /// <param name="isHidden">Optional filter by IsHidden=true or false.</param> + /// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param> + /// <response code="200">Users returned.</response> + /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns> + [HttpGet] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<UserDto>> GetUsers( + [FromQuery] bool? isHidden, + [FromQuery] bool? isDisabled) + { + var users = Get(isHidden, isDisabled, false, false); + return Ok(users); + } + /// <summary> - /// User controller. + /// Gets a list of publicly visible users for display on a login screen. /// </summary> - [Route("Users")] - public class UserController : BaseJellyfinApiController + /// <response code="200">Public users returned.</response> + /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns> + [HttpGet("Public")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<UserDto>> GetPublicUsers() { - private readonly IUserManager _userManager; - private readonly ISessionManager _sessionManager; - private readonly INetworkManager _networkManager; - private readonly IDeviceManager _deviceManager; - private readonly IAuthorizationContext _authContext; - private readonly IServerConfigurationManager _config; - private readonly ILogger _logger; - private readonly IQuickConnect _quickConnectManager; - - /// <summary> - /// Initializes a new instance of the <see cref="UserController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> - /// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> - /// <param name="quickConnectManager">Instance of the <see cref="IQuickConnect"/> interface.</param> - public UserController( - IUserManager userManager, - ISessionManager sessionManager, - INetworkManager networkManager, - IDeviceManager deviceManager, - IAuthorizationContext authContext, - IServerConfigurationManager config, - ILogger<UserController> logger, - IQuickConnect quickConnectManager) + // If the startup wizard hasn't been completed then just return all users + if (!_config.Configuration.IsStartupWizardCompleted) { - _userManager = userManager; - _sessionManager = sessionManager; - _networkManager = networkManager; - _deviceManager = deviceManager; - _authContext = authContext; - _config = config; - _logger = logger; - _quickConnectManager = quickConnectManager; + return Ok(Get(false, false, false, false)); } - /// <summary> - /// Gets a list of users. - /// </summary> - /// <param name="isHidden">Optional filter by IsHidden=true or false.</param> - /// <param name="isDisabled">Optional filter by IsDisabled=true or false.</param> - /// <response code="200">Users returned.</response> - /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the users.</returns> - [HttpGet] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<UserDto>> GetUsers( - [FromQuery] bool? isHidden, - [FromQuery] bool? isDisabled) + return Ok(Get(false, false, true, true)); + } + + /// <summary> + /// Gets a user by Id. + /// </summary> + /// <param name="userId">The user id.</param> + /// <response code="200">User returned.</response> + /// <response code="404">User not found.</response> + /// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns> + [HttpGet("{userId}")] + [Authorize(Policy = Policies.IgnoreParentalControl)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<UserDto> GetUserById([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + + if (user is null) { - var users = Get(isHidden, isDisabled, false, false); - return Ok(users); + return NotFound("User not found"); } - /// <summary> - /// Gets a list of publicly visible users for display on a login screen. - /// </summary> - /// <response code="200">Public users returned.</response> - /// <returns>An <see cref="IEnumerable{UserDto}"/> containing the public users.</returns> - [HttpGet("Public")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<UserDto>> GetPublicUsers() - { - // If the startup wizard hasn't been completed then just return all users - if (!_config.Configuration.IsStartupWizardCompleted) - { - return Ok(Get(false, false, false, false)); - } + var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString()); + return result; + } + + /// <summary> + /// Deletes a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <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}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false); + await _userManager.DeleteUserAsync(userId).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Authenticates a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="pw">The password as plain text.</param> + /// <response code="200">User authenticated.</response> + /// <response code="403">Sha1-hashed password only is not allowed.</response> + /// <response code="404">User not found.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns> + [HttpPost("{userId}/Authenticate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Authenticate with username instead")] + public async Task<ActionResult<AuthenticationResult>> AuthenticateUser( + [FromRoute, Required] Guid userId, + [FromQuery, Required] string pw) + { + var user = _userManager.GetUserById(userId); - return Ok(Get(false, false, true, true)); + if (user is null) + { + return NotFound("User not found"); } - /// <summary> - /// Gets a user by Id. - /// </summary> - /// <param name="userId">The user id.</param> - /// <response code="200">User returned.</response> - /// <response code="404">User not found.</response> - /// <returns>An <see cref="UserDto"/> with information about the user or a <see cref="NotFoundResult"/> if the user was not found.</returns> - [HttpGet("{userId}")] - [Authorize(Policy = Policies.IgnoreParentalControl)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<UserDto> GetUserById([FromRoute, Required] Guid userId) + AuthenticateUserByName request = new AuthenticateUserByName { - var user = _userManager.GetUserById(userId); + Username = user.Username, + Pw = pw + }; + return await AuthenticateUserByName(request).ConfigureAwait(false); + } + + /// <summary> + /// Authenticates a user by name. + /// </summary> + /// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param> + /// <response code="200">User authenticated.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> + [HttpPost("AuthenticateByName")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request) + { + var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); - if (user is null) + try + { + var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest { - return NotFound("User not found"); - } + App = auth.Client, + AppVersion = auth.Version, + DeviceId = auth.DeviceId, + DeviceName = auth.Device, + Password = request.Pw, + RemoteEndPoint = HttpContext.GetNormalizedRemoteIp().ToString(), + Username = request.Username + }).ConfigureAwait(false); - var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString()); return result; } - - /// <summary> - /// Deletes a user. - /// </summary> - /// <param name="userId">The user id.</param> - /// <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}")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteUser([FromRoute, Required] Guid userId) + catch (SecurityException e) { - var user = _userManager.GetUserById(userId); - await _sessionManager.RevokeUserTokens(user.Id, null).ConfigureAwait(false); - await _userManager.DeleteUserAsync(userId).ConfigureAwait(false); - return NoContent(); + // rethrow adding IP address to message + throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); } + } - /// <summary> - /// Authenticates a user. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="pw">The password as plain text.</param> - /// <response code="200">User authenticated.</response> - /// <response code="403">Sha1-hashed password only is not allowed.</response> - /// <response code="404">User not found.</response> - /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationResult"/>.</returns> - [HttpPost("{userId}/Authenticate")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [Obsolete("Authenticate with username instead")] - public async Task<ActionResult<AuthenticationResult>> AuthenticateUser( - [FromRoute, Required] Guid userId, - [FromQuery, Required] string pw) + /// <summary> + /// Authenticates a user with quick connect. + /// </summary> + /// <param name="request">The <see cref="QuickConnectDto"/> request.</param> + /// <response code="200">User authenticated.</response> + /// <response code="400">Missing token.</response> + /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> + [HttpPost("AuthenticateWithQuickConnect")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request) + { + try { - var user = _userManager.GetUserById(userId); - - if (user is null) - { - return NotFound("User not found"); - } - - AuthenticateUserByName request = new AuthenticateUserByName - { - Username = user.Username, - Pw = pw - }; - return await AuthenticateUserByName(request).ConfigureAwait(false); + return _quickConnectManager.GetAuthorizedRequest(request.Secret); } - - /// <summary> - /// Authenticates a user by name. - /// </summary> - /// <param name="request">The <see cref="AuthenticateUserByName"/> request.</param> - /// <response code="200">User authenticated.</response> - /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> - [HttpPost("AuthenticateByName")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request) + catch (SecurityException e) { - var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false); + // rethrow adding IP address to message + throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); + } + } - try - { - var result = await _sessionManager.AuthenticateNewSession(new AuthenticationRequest - { - App = auth.Client, - AppVersion = auth.Version, - DeviceId = auth.DeviceId, - DeviceName = auth.Device, - Password = request.Pw, - RemoteEndPoint = HttpContext.GetNormalizedRemoteIp().ToString(), - Username = request.Username - }).ConfigureAwait(false); - - return result; - } - catch (SecurityException e) - { - // rethrow adding IP address to message - throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); - } + /// <summary> + /// Updates a user's password. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param> + /// <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> + [HttpPost("{userId}/Password")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdateUserPassword( + [FromRoute, Required] Guid userId, + [FromBody, Required] UpdateUserPassword request) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password."); } - /// <summary> - /// Authenticates a user with quick connect. - /// </summary> - /// <param name="request">The <see cref="QuickConnectDto"/> request.</param> - /// <response code="200">User authenticated.</response> - /// <response code="400">Missing token.</response> - /// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns> - [HttpPost("AuthenticateWithQuickConnect")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request) + var user = _userManager.GetUserById(userId); + + if (user is null) { - try - { - return _quickConnectManager.GetAuthorizedRequest(request.Secret); - } - catch (SecurityException e) - { - // rethrow adding IP address to message - throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); - } + return NotFound("User not found"); } - /// <summary> - /// Updates a user's password. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="request">The <see cref="UpdateUserPassword"/> request.</param> - /// <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> - [HttpPost("{userId}/Password")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> UpdateUserPassword( - [FromRoute, Required] Guid userId, - [FromBody, Required] UpdateUserPassword request) + if (request.ResetPassword) { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + await _userManager.ResetPassword(user).ConfigureAwait(false); + } + else + { + if (!User.IsInRole(UserRoles.Administrator) || User.GetUserId().Equals(userId)) { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the password."); + var success = await _userManager.AuthenticateUser( + user.Username, + request.CurrentPw, + request.CurrentPw, + HttpContext.GetNormalizedRemoteIp().ToString(), + false).ConfigureAwait(false); + + if (success is null) + { + return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered."); + } } - var user = _userManager.GetUserById(userId); + await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); - if (user is null) - { - return NotFound("User not found"); - } + var currentToken = User.GetToken(); - if (request.ResetPassword) - { - await _userManager.ResetPassword(user).ConfigureAwait(false); - } - else - { - if (!User.IsInRole(UserRoles.Administrator) || User.GetUserId().Equals(userId)) - { - var success = await _userManager.AuthenticateUser( - user.Username, - request.CurrentPw, - request.CurrentPw, - HttpContext.GetNormalizedRemoteIp().ToString(), - false).ConfigureAwait(false); - - if (success is null) - { - return StatusCode(StatusCodes.Status403Forbidden, "Invalid user or password entered."); - } - } + await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); + } - await _userManager.ChangePassword(user, request.NewPw).ConfigureAwait(false); + return NoContent(); + } - var currentToken = User.GetToken(); + /// <summary> + /// Updates a user's easy password. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param> + /// <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> + [HttpPost("{userId}/EasyPassword")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdateUserEasyPassword( + [FromRoute, Required] Guid userId, + [FromBody, Required] UpdateUserEasyPassword request) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password."); + } - await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); - } + var user = _userManager.GetUserById(userId); - return NoContent(); + if (user is null) + { + return NotFound("User not found"); } - /// <summary> - /// Updates a user's easy password. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param> - /// <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> - [HttpPost("{userId}/EasyPassword")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> UpdateUserEasyPassword( - [FromRoute, Required] Guid userId, - [FromBody, Required] UpdateUserEasyPassword request) + if (request.ResetPassword) { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to update the easy password."); - } + await _userManager.ResetEasyPassword(user).ConfigureAwait(false); + } + else + { + await _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword).ConfigureAwait(false); + } - var user = _userManager.GetUserById(userId); + return NoContent(); + } - if (user is null) - { - return NotFound("User not found"); - } + /// <summary> + /// Updates a user. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="updateUser">The updated user model.</param> + /// <response code="204">User updated.</response> + /// <response code="400">User information was not supplied.</response> + /// <response code="403">User update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns> + [HttpPost("{userId}")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> UpdateUser( + [FromRoute, Required] Guid userId, + [FromBody, Required] UserDto updateUser) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed."); + } - if (request.ResetPassword) - { - await _userManager.ResetEasyPassword(user).ConfigureAwait(false); - } - else - { - await _userManager.ChangeEasyPassword(user, request.NewPw, request.NewPassword).ConfigureAwait(false); - } + var user = _userManager.GetUserById(userId); - return NoContent(); + if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) + { + await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); } - /// <summary> - /// Updates a user. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="updateUser">The updated user model.</param> - /// <response code="204">User updated.</response> - /// <response code="400">User information was not supplied.</response> - /// <response code="403">User update forbidden.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure.</returns> - [HttpPost("{userId}")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult> UpdateUser( - [FromRoute, Required] Guid userId, - [FromBody, Required] UserDto updateUser) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User update not allowed."); - } + await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false); - var user = _userManager.GetUserById(userId); + return NoContent(); + } + + /// <summary> + /// Updates a user policy. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="newPolicy">The new user policy.</param> + /// <response code="204">User policy updated.</response> + /// <response code="400">User policy was not supplied.</response> + /// <response code="403">User policy update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns> + [HttpPost("{userId}/Policy")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> UpdateUserPolicy( + [FromRoute, Required] Guid userId, + [FromBody, Required] UserPolicy newPolicy) + { + var user = _userManager.GetUserById(userId); - if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal)) + // If removing admin access + if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) + { + if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) { - await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false); + return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access."); } - - await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false); - - return NoContent(); } - /// <summary> - /// Updates a user policy. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="newPolicy">The new user policy.</param> - /// <response code="204">User policy updated.</response> - /// <response code="400">User policy was not supplied.</response> - /// <response code="403">User policy update forbidden.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="BadRequestResult"/> or a <see cref="ForbidResult"/> on failure..</returns> - [HttpPost("{userId}/Policy")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult> UpdateUserPolicy( - [FromRoute, Required] Guid userId, - [FromBody, Required] UserPolicy newPolicy) + // If disabling + if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) { - var user = _userManager.GetUserById(userId); - - // If removing admin access - if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator)) - { - if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) - { - return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access."); - } - } + return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled."); + } - // If disabling - if (newPolicy.IsDisabled && user.HasPermission(PermissionKind.IsAdministrator)) + // If disabling + if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) + { + if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) { - return StatusCode(StatusCodes.Status403Forbidden, "Administrators cannot be disabled."); + return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); } - // If disabling - if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled)) - { - if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1) - { - return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system."); - } + var currentToken = User.GetToken(); + await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); + } - var currentToken = User.GetToken(); - await _sessionManager.RevokeUserTokens(user.Id, currentToken).ConfigureAwait(false); - } + await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false); - await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false); + return NoContent(); + } - return NoContent(); + /// <summary> + /// Updates a user configuration. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="userConfig">The new user configuration.</param> + /// <response code="204">User configuration updated.</response> + /// <response code="403">User configuration update forbidden.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("{userId}/Configuration")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task<ActionResult> UpdateUserConfiguration( + [FromRoute, Required] Guid userId, + [FromBody, Required] UserConfiguration userConfig) + { + if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) + { + return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed"); } - /// <summary> - /// Updates a user configuration. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="userConfig">The new user configuration.</param> - /// <response code="204">User configuration updated.</response> - /// <response code="403">User configuration update forbidden.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> - [HttpPost("{userId}/Configuration")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<ActionResult> UpdateUserConfiguration( - [FromRoute, Required] Guid userId, - [FromBody, Required] UserConfiguration userConfig) - { - if (!RequestHelpers.AssertCanUpdateUser(_userManager, User, userId, true)) - { - return StatusCode(StatusCodes.Status403Forbidden, "User configuration update not allowed"); - } + await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); - await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false); + return NoContent(); + } - return NoContent(); - } + /// <summary> + /// Creates a user. + /// </summary> + /// <param name="request">The create user by name request body.</param> + /// <response code="200">User created.</response> + /// <returns>An <see cref="UserDto"/> of the new user.</returns> + [HttpPost("New")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<UserDto>> CreateUserByName([FromBody, Required] CreateUserByName request) + { + var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false); - /// <summary> - /// Creates a user. - /// </summary> - /// <param name="request">The create user by name request body.</param> - /// <response code="200">User created.</response> - /// <returns>An <see cref="UserDto"/> of the new user.</returns> - [HttpPost("New")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<UserDto>> CreateUserByName([FromBody, Required] CreateUserByName request) + // no need to authenticate password for new user + if (request.Password is not null) { - var newUser = await _userManager.CreateUserAsync(request.Name).ConfigureAwait(false); + await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); + } - // no need to authenticate password for new user - if (request.Password is not null) - { - await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); - } + var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString()); - var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString()); + return result; + } - return result; - } + /// <summary> + /// Initiates the forgot password process for a local user. + /// </summary> + /// <param name="forgotPasswordRequest">The forgot password request containing the entered username.</param> + /// <response code="200">Password reset process started.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns> + [HttpPost("ForgotPassword")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest) + { + var ip = HttpContext.GetNormalizedRemoteIp(); + var isLocal = HttpContext.IsLocal() + || _networkManager.IsInLocalNetwork(ip); - /// <summary> - /// Initiates the forgot password process for a local user. - /// </summary> - /// <param name="forgotPasswordRequest">The forgot password request containing the entered username.</param> - /// <response code="200">Password reset process started.</response> - /// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns> - [HttpPost("ForgotPassword")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest) + if (isLocal) { - var ip = HttpContext.GetNormalizedRemoteIp(); - var isLocal = HttpContext.IsLocal() - || _networkManager.IsInLocalNetwork(ip); + _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip); + } - if (isLocal) - { - _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip); - } + var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false); - var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false); + return result; + } - return result; - } + /// <summary> + /// Redeems a forgot password pin. + /// </summary> + /// <param name="forgotPasswordPinRequest">The forgot password pin request containing the entered pin.</param> + /// <response code="200">Pin reset process started.</response> + /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> + [HttpPost("ForgotPassword/Pin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest) + { + var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false); + return result; + } - /// <summary> - /// Redeems a forgot password pin. - /// </summary> - /// <param name="forgotPasswordPinRequest">The forgot password pin request containing the entered pin.</param> - /// <response code="200">Pin reset process started.</response> - /// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns> - [HttpPost("ForgotPassword/Pin")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest) + /// <summary> + /// Gets the user based on auth token. + /// </summary> + /// <response code="200">User returned.</response> + /// <response code="400">Token is not owned by a user.</response> + /// <returns>A <see cref="UserDto"/> for the authenticated user.</returns> + [HttpGet("Me")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult<UserDto> GetCurrentUser() + { + var userId = User.GetUserId(); + if (userId.Equals(default)) { - var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false); - return result; + return BadRequest(); } - /// <summary> - /// Gets the user based on auth token. - /// </summary> - /// <response code="200">User returned.</response> - /// <response code="400">Token is not owned by a user.</response> - /// <returns>A <see cref="UserDto"/> for the authenticated user.</returns> - [HttpGet("Me")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public ActionResult<UserDto> GetCurrentUser() + var user = _userManager.GetUserById(userId); + if (user is null) { - var userId = User.GetUserId(); - if (userId.Equals(default)) - { - return BadRequest(); - } + return BadRequest(); + } - var user = _userManager.GetUserById(userId); - if (user is null) - { - return BadRequest(); - } + return _userManager.GetUserDto(user); + } - return _userManager.GetUserDto(user); - } + private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + { + var users = _userManager.Users; - private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork) + if (isDisabled.HasValue) { - var users = _userManager.Users; + users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value); + } - if (isDisabled.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsDisabled) == isDisabled.Value); - } + if (isHidden.HasValue) + { + users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value); + } - if (isHidden.HasValue) - { - users = users.Where(i => i.HasPermission(PermissionKind.IsHidden) == isHidden.Value); - } + if (filterByDevice) + { + var deviceId = User.GetDeviceId(); - if (filterByDevice) + if (!string.IsNullOrWhiteSpace(deviceId)) { - var deviceId = User.GetDeviceId(); - - if (!string.IsNullOrWhiteSpace(deviceId)) - { - users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); - } + users = users.Where(i => _deviceManager.CanAccessDevice(i, deviceId)); } + } - if (filterByNetwork) + if (filterByNetwork) + { + if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())) { - if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())) - { - users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); - } + users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); } + } - var result = users - .OrderBy(u => u.Username) - .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp().ToString())); + var result = users + .OrderBy(u => u.Username) + .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp().ToString())); - return result; - } + return result; } } diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index cd21c5f6f..556cf3894 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -7,7 +7,6 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.ModelBinders; -using Jellyfin.Api.Models.UserDtos; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -23,406 +22,405 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// User library controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class UserLibraryController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataRepository; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IUserViewManager _userViewManager; + private readonly IFileSystem _fileSystem; + private readonly ILyricManager _lyricManager; + /// <summary> - /// User library controller. + /// Initializes a new instance of the <see cref="UserLibraryController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class UserLibraryController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param> + public UserLibraryController( + IUserManager userManager, + IUserDataManager userDataRepository, + ILibraryManager libraryManager, + IDtoService dtoService, + IUserViewManager userViewManager, + IFileSystem fileSystem, + ILyricManager lyricManager) { - private readonly IUserManager _userManager; - private readonly IUserDataManager _userDataRepository; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IUserViewManager _userViewManager; - private readonly IFileSystem _fileSystem; - private readonly ILyricManager _lyricManager; - - /// <summary> - /// Initializes a new instance of the <see cref="UserLibraryController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param> - public UserLibraryController( - IUserManager userManager, - IUserDataManager userDataRepository, - ILibraryManager libraryManager, - IDtoService dtoService, - IUserViewManager userViewManager, - IFileSystem fileSystem, - ILyricManager lyricManager) - { - _userManager = userManager; - _userDataRepository = userDataRepository; - _libraryManager = libraryManager; - _dtoService = dtoService; - _userViewManager = userViewManager; - _fileSystem = fileSystem; - _lyricManager = lyricManager; - } - - /// <summary> - /// Gets an item from a user's library. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Item returned.</response> - /// <returns>An <see cref="OkResult"/> containing the d item.</returns> - [HttpGet("Users/{userId}/Items/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - var user = _userManager.GetUserById(userId); + _userManager = userManager; + _userDataRepository = userDataRepository; + _libraryManager = libraryManager; + _dtoService = dtoService; + _userViewManager = userViewManager; + _fileSystem = fileSystem; + _lyricManager = lyricManager; + } - var item = itemId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); + /// <summary> + /// Gets an item from a user's library. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item returned.</response> + /// <returns>An <see cref="OkResult"/> containing the d item.</returns> + [HttpGet("Users/{userId}/Items/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); - await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); - var dtoOptions = new DtoOptions().AddClientFields(User); + await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + var dtoOptions = new DtoOptions().AddClientFields(User); - /// <summary> - /// Gets the root folder from a user's library. - /// </summary> - /// <param name="userId">User id.</param> - /// <response code="200">Root folder returned.</response> - /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns> - [HttpGet("Users/{userId}/Items/Root")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<BaseItemDto> GetRootFolder([FromRoute, Required] Guid userId) - { - var user = _userManager.GetUserById(userId); - var item = _libraryManager.GetUserRootFolder(); - var dtoOptions = new DtoOptions().AddClientFields(User); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } - /// <summary> - /// Gets intros to play before the main media item plays. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Intros returned.</response> - /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns> - [HttpGet("Users/{userId}/Items/{itemId}/Intros")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - var user = _userManager.GetUserById(userId); + /// <summary> + /// Gets the root folder from a user's library. + /// </summary> + /// <param name="userId">User id.</param> + /// <response code="200">Root folder returned.</response> + /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns> + [HttpGet("Users/{userId}/Items/Root")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<BaseItemDto> GetRootFolder([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + var item = _libraryManager.GetUserRootFolder(); + var dtoOptions = new DtoOptions().AddClientFields(User); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } - var item = itemId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); + /// <summary> + /// Gets intros to play before the main media item plays. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Intros returned.</response> + /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/Intros")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); - var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false); - var dtoOptions = new DtoOptions().AddClientFields(User); - var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray(); + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); - return new QueryResult<BaseItemDto>(dtos); - } + var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false); + var dtoOptions = new DtoOptions().AddClientFields(User); + var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray(); - /// <summary> - /// Marks an item as a favorite. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Item marked as favorite.</response> - /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> - [HttpPost("Users/{userId}/FavoriteItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - return MarkFavorite(userId, itemId, true); - } + return new QueryResult<BaseItemDto>(dtos); + } - /// <summary> - /// Unmarks item as a favorite. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Item unmarked as favorite.</response> - /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> - [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - return MarkFavorite(userId, itemId, false); - } + /// <summary> + /// Marks an item as a favorite. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item marked as favorite.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpPost("Users/{userId}/FavoriteItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + return MarkFavorite(userId, itemId, true); + } - /// <summary> - /// Deletes a user's saved personal rating for an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Personal rating removed.</response> - /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> - [HttpDelete("Users/{userId}/Items/{itemId}/Rating")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - return UpdateUserItemRatingInternal(userId, itemId, null); - } + /// <summary> + /// Unmarks item as a favorite. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Item unmarked as favorite.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + return MarkFavorite(userId, itemId, false); + } - /// <summary> - /// Updates a user's rating for an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param> - /// <response code="200">Item rating updated.</response> - /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> - [HttpPost("Users/{userId}/Items/{itemId}/Rating")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) - { - return UpdateUserItemRatingInternal(userId, itemId, likes); - } + /// <summary> + /// Deletes a user's saved personal rating for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Personal rating removed.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpDelete("Users/{userId}/Items/{itemId}/Rating")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + return UpdateUserItemRatingInternal(userId, itemId, null); + } - /// <summary> - /// Gets local trailers for an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response> - /// <returns>The items local trailers.</returns> - [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - var user = _userManager.GetUserById(userId); + /// <summary> + /// Updates a user's rating for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param> + /// <response code="200">Item rating updated.</response> + /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns> + [HttpPost("Users/{userId}/Items/{itemId}/Rating")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) + { + return UpdateUserItemRatingInternal(userId, itemId, likes); + } - var item = itemId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); + /// <summary> + /// Gets local trailers for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response> + /// <returns>The items local trailers.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); - var dtoOptions = new DtoOptions().AddClientFields(User); + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); - if (item is IHasTrailers hasTrailers) - { - var trailers = hasTrailers.LocalTrailers; - return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable()); - } + var dtoOptions = new DtoOptions().AddClientFields(User); - return Ok(item.GetExtras() - .Where(e => e.ExtraType == ExtraType.Trailer) - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); + if (item is IHasTrailers hasTrailers) + { + var trailers = hasTrailers.LocalTrailers; + return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable()); } - /// <summary> - /// Gets special features for an item. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Special features returned.</response> - /// <returns>An <see cref="OkResult"/> containing the special features.</returns> - [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - var user = _userManager.GetUserById(userId); + return Ok(item.GetExtras() + .Where(e => e.ExtraType == ExtraType.Trailer) + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); + } - var item = itemId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); + /// <summary> + /// Gets special features for an item. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Special features returned.</response> + /// <returns>An <see cref="OkResult"/> containing the special features.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); - var dtoOptions = new DtoOptions().AddClientFields(User); + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); - return Ok(item - .GetExtras() - .Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value)) - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); - } + var dtoOptions = new DtoOptions().AddClientFields(User); - /// <summary> - /// Gets latest media. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> - /// <param name="isPlayed">Filter by items that are played, or not.</param> - /// <param name="enableImages">Optional. include image information in output.</param> - /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="enableUserData">Optional. include user data.</param> - /// <param name="limit">Return item limit.</param> - /// <param name="groupItems">Whether or not to group items into a parent container.</param> - /// <response code="200">Latest media returned.</response> - /// <returns>An <see cref="OkResult"/> containing the latest media.</returns> - [HttpGet("Users/{userId}/Items/Latest")] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia( - [FromRoute, Required] Guid userId, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery] bool? isPlayed, - [FromQuery] bool? enableImages, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] bool? enableUserData, - [FromQuery] int limit = 20, - [FromQuery] bool groupItems = true) - { - var user = _userManager.GetUserById(userId); + return Ok(item + .GetExtras() + .Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value)) + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); + } - if (!isPlayed.HasValue) + /// <summary> + /// Gets latest media. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> + /// <param name="isPlayed">Filter by items that are played, or not.</param> + /// <param name="enableImages">Optional. include image information in output.</param> + /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="enableUserData">Optional. include user data.</param> + /// <param name="limit">Return item limit.</param> + /// <param name="groupItems">Whether or not to group items into a parent container.</param> + /// <response code="200">Latest media returned.</response> + /// <returns>An <see cref="OkResult"/> containing the latest media.</returns> + [HttpGet("Users/{userId}/Items/Latest")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia( + [FromRoute, Required] Guid userId, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery] bool? isPlayed, + [FromQuery] bool? enableImages, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] bool? enableUserData, + [FromQuery] int limit = 20, + [FromQuery] bool groupItems = true) + { + var user = _userManager.GetUserById(userId); + + if (!isPlayed.HasValue) + { + if (user.HidePlayedInLatest) { - if (user.HidePlayedInLatest) - { - isPlayed = false; - } + isPlayed = false; } + } - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var list = _userViewManager.GetLatestItems( - new LatestItemsQuery - { - GroupItems = groupItems, - IncludeItemTypes = includeItemTypes, - IsPlayed = isPlayed, - Limit = limit, - ParentId = parentId ?? Guid.Empty, - UserId = userId, - }, - dtoOptions); - - var dtos = list.Select(i => + var list = _userViewManager.GetLatestItems( + new LatestItemsQuery { - var item = i.Item2[0]; - var childCount = 0; + GroupItems = groupItems, + IncludeItemTypes = includeItemTypes, + IsPlayed = isPlayed, + Limit = limit, + ParentId = parentId ?? Guid.Empty, + UserId = userId, + }, + dtoOptions); + + var dtos = list.Select(i => + { + var item = i.Item2[0]; + var childCount = 0; - if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum)) - { - item = i.Item1; - childCount = i.Item2.Count; - } + if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum)) + { + item = i.Item1; + childCount = i.Item2.Count; + } - var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user); + var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user); - dto.ChildCount = childCount; + dto.ChildCount = childCount; - return dto; - }); + return dto; + }); - return Ok(dtos); - } + return Ok(dtos); + } - private async Task RefreshItemOnDemandIfNeeded(BaseItem item) + private async Task RefreshItemOnDemandIfNeeded(BaseItem item) + { + if (item is Person) { - if (item is Person) - { - var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); - var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; + var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); + var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; - if (!hasMetdata) + if (!hasMetdata) + { + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ForceSave = performFullRefresh - }; - - await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); - } + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ForceSave = performFullRefresh + }; + + await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); } } + } - /// <summary> - /// Marks the favorite. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="itemId">The item id.</param> - /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param> - private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite) - { - var user = _userManager.GetUserById(userId); - - var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); - - // Get the user data for this item - var data = _userDataRepository.GetUserData(user, item); + /// <summary> + /// Marks the favorite. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param> + private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite) + { + var user = _userManager.GetUserById(userId); - // Set favorite status - data.IsFavorite = isFavorite; + var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); - _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + // Get the user data for this item + var data = _userDataRepository.GetUserData(user, item); - return _userDataRepository.GetUserDataDto(item, user); - } + // Set favorite status + data.IsFavorite = isFavorite; - /// <summary> - /// Updates the user item rating. - /// </summary> - /// <param name="userId">The user id.</param> - /// <param name="itemId">The item id.</param> - /// <param name="likes">if set to <c>true</c> [likes].</param> - private UserItemDataDto UpdateUserItemRatingInternal(Guid userId, Guid itemId, bool? likes) - { - var user = _userManager.GetUserById(userId); + _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); - var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); + return _userDataRepository.GetUserDataDto(item, user); + } - // Get the user data for this item - var data = _userDataRepository.GetUserData(user, item); + /// <summary> + /// Updates the user item rating. + /// </summary> + /// <param name="userId">The user id.</param> + /// <param name="itemId">The item id.</param> + /// <param name="likes">if set to <c>true</c> [likes].</param> + private UserItemDataDto UpdateUserItemRatingInternal(Guid userId, Guid itemId, bool? likes) + { + var user = _userManager.GetUserById(userId); - data.Likes = likes; + var item = itemId.Equals(default) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId); - _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); + // Get the user data for this item + var data = _userDataRepository.GetUserData(user, item); - return _userDataRepository.GetUserDataDto(item, user); - } + data.Likes = likes; - /// <summary> - /// Gets an item's lyrics. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Lyrics returned.</response> - /// <response code="404">Something went wrong. No Lyrics will be returned.</response> - /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns> - [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<LyricResponse>> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - var user = _userManager.GetUserById(userId); + _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None); - if (user is null) - { - return NotFound(); - } + return _userDataRepository.GetUserDataDto(item, user); + } - var item = itemId.Equals(default) - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); + /// <summary> + /// Gets an item's lyrics. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="itemId">Item id.</param> + /// <response code="200">Lyrics returned.</response> + /// <response code="404">Something went wrong. No Lyrics will be returned.</response> + /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns> + [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task<ActionResult<LyricResponse>> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) + { + var user = _userManager.GetUserById(userId); - if (item is null) - { - return NotFound(); - } + if (user is null) + { + return NotFound(); + } - var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false); - if (result is not null) - { - return Ok(result); - } + var item = itemId.Equals(default) + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById(itemId); + if (item is null) + { return NotFound(); } + + var result = await _lyricManager.GetLyrics(item).ConfigureAwait(false); + if (result is not null) + { + return Ok(result); + } + + return NotFound(); } } diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index 3aeb444df..aa7ba8891 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -17,122 +17,121 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// User views controller. +/// </summary> +[Route("")] +[Authorize(Policy = Policies.DefaultAuthorization)] +public class UserViewsController : BaseJellyfinApiController { + private readonly IUserManager _userManager; + private readonly IUserViewManager _userViewManager; + private readonly IDtoService _dtoService; + private readonly ILibraryManager _libraryManager; + /// <summary> - /// User views controller. + /// Initializes a new instance of the <see cref="UserViewsController"/> class. /// </summary> - [Route("")] - [Authorize(Policy = Policies.DefaultAuthorization)] - public class UserViewsController : BaseJellyfinApiController + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public UserViewsController( + IUserManager userManager, + IUserViewManager userViewManager, + IDtoService dtoService, + ILibraryManager libraryManager) { - private readonly IUserManager _userManager; - private readonly IUserViewManager _userViewManager; - private readonly IDtoService _dtoService; - private readonly ILibraryManager _libraryManager; + _userManager = userManager; + _userViewManager = userViewManager; + _dtoService = dtoService; + _libraryManager = libraryManager; + } - /// <summary> - /// Initializes a new instance of the <see cref="UserViewsController"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - public UserViewsController( - IUserManager userManager, - IUserViewManager userViewManager, - IDtoService dtoService, - ILibraryManager libraryManager) + /// <summary> + /// Get user views. + /// </summary> + /// <param name="userId">User id.</param> + /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param> + /// <param name="presetViews">Preset views.</param> + /// <param name="includeHidden">Whether or not to include hidden content.</param> + /// <response code="200">User views returned.</response> + /// <returns>An <see cref="OkResult"/> containing the user views.</returns> + [HttpGet("Users/{userId}/Views")] + [ProducesResponseType(StatusCodes.Status200OK)] + public QueryResult<BaseItemDto> GetUserViews( + [FromRoute, Required] Guid userId, + [FromQuery] bool? includeExternalContent, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews, + [FromQuery] bool includeHidden = false) + { + var query = new UserViewQuery { - _userManager = userManager; - _userViewManager = userViewManager; - _dtoService = dtoService; - _libraryManager = libraryManager; - } + UserId = userId, + IncludeHidden = includeHidden + }; - /// <summary> - /// Get user views. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param> - /// <param name="presetViews">Preset views.</param> - /// <param name="includeHidden">Whether or not to include hidden content.</param> - /// <response code="200">User views returned.</response> - /// <returns>An <see cref="OkResult"/> containing the user views.</returns> - [HttpGet("Users/{userId}/Views")] - [ProducesResponseType(StatusCodes.Status200OK)] - public QueryResult<BaseItemDto> GetUserViews( - [FromRoute, Required] Guid userId, - [FromQuery] bool? includeExternalContent, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews, - [FromQuery] bool includeHidden = false) + if (includeExternalContent.HasValue) { - var query = new UserViewQuery - { - UserId = userId, - IncludeHidden = includeHidden - }; + query.IncludeExternalContent = includeExternalContent.Value; + } - if (includeExternalContent.HasValue) - { - query.IncludeExternalContent = includeExternalContent.Value; - } + if (presetViews.Length != 0) + { + query.PresetViews = presetViews; + } - if (presetViews.Length != 0) - { - query.PresetViews = presetViews; - } + var folders = _userViewManager.GetUserViews(query); - var folders = _userViewManager.GetUserViews(query); + var dtoOptions = new DtoOptions().AddClientFields(User); + var fields = dtoOptions.Fields.ToList(); - var dtoOptions = new DtoOptions().AddClientFields(User); - var fields = dtoOptions.Fields.ToList(); + fields.Add(ItemFields.PrimaryImageAspectRatio); + fields.Add(ItemFields.DisplayPreferencesId); + fields.Remove(ItemFields.BasicSyncInfo); + dtoOptions.Fields = fields.ToArray(); - fields.Add(ItemFields.PrimaryImageAspectRatio); - fields.Add(ItemFields.DisplayPreferencesId); - fields.Remove(ItemFields.BasicSyncInfo); - dtoOptions.Fields = fields.ToArray(); + var user = _userManager.GetUserById(userId); - var user = _userManager.GetUserById(userId); + var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)) + .ToArray(); - var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)) - .ToArray(); + return new QueryResult<BaseItemDto>(dtos); + } - return new QueryResult<BaseItemDto>(dtos); + /// <summary> + /// Get user view grouping options. + /// </summary> + /// <param name="userId">User id.</param> + /// <response code="200">User view grouping options returned.</response> + /// <response code="404">User not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the user view grouping options + /// or a <see cref="NotFoundResult"/> if user not found. + /// </returns> + [HttpGet("Users/{userId}/GroupingOptions")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute, Required] Guid userId) + { + var user = _userManager.GetUserById(userId); + if (user is null) + { + return NotFound(); } - /// <summary> - /// Get user view grouping options. - /// </summary> - /// <param name="userId">User id.</param> - /// <response code="200">User view grouping options returned.</response> - /// <response code="404">User not found.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the user view grouping options - /// or a <see cref="NotFoundResult"/> if user not found. - /// </returns> - [HttpGet("Users/{userId}/GroupingOptions")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute, Required] Guid userId) - { - var user = _userManager.GetUserById(userId); - if (user is null) + return Ok(_libraryManager.GetUserRootFolder() + .GetChildren(user, true) + .OfType<Folder>() + .Where(UserView.IsEligibleForGrouping) + .Select(i => new SpecialViewOptionDto { - return NotFound(); - } - - return Ok(_libraryManager.GetUserRootFolder() - .GetChildren(user, true) - .OfType<Folder>() - .Where(UserView.IsEligibleForGrouping) - .Select(i => new SpecialViewOptionDto - { - Name = i.Name, - Id = i.Id.ToString("N", CultureInfo.InvariantCulture) - }) - .OrderBy(i => i.Name) - .AsEnumerable()); - } + Name = i.Name, + Id = i.Id.ToString("N", CultureInfo.InvariantCulture) + }) + .OrderBy(i => i.Name) + .AsEnumerable()); } } diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index bb3162614..23b9ba46f 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -10,73 +10,72 @@ using MediaBrowser.Controller.MediaEncoding; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Attachments controller. +/// </summary> +[Route("Videos")] +public class VideoAttachmentsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IAttachmentExtractor _attachmentExtractor; + /// <summary> - /// Attachments controller. + /// Initializes a new instance of the <see cref="VideoAttachmentsController"/> class. /// </summary> - [Route("Videos")] - public class VideoAttachmentsController : BaseJellyfinApiController + /// <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) { - 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; - } + _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}")] - [ProducesFile(MediaTypeNames.Application.Octet)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> GetAttachment( - [FromRoute, Required] Guid videoId, - [FromRoute, Required] string mediaSourceId, - [FromRoute, Required] int index) + /// <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}")] + [ProducesFile(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> GetAttachment( + [FromRoute, Required] Guid videoId, + [FromRoute, Required] string mediaSourceId, + [FromRoute, Required] int index) + { + try { - try + var item = _libraryManager.GetItemById(videoId); + if (item is null) { - var item = _libraryManager.GetItemById(videoId); - if (item is null) - { - return NotFound(); - } + return NotFound(); + } - var (attachment, stream) = await _attachmentExtractor.GetAttachment( - item, - mediaSourceId, - index, - CancellationToken.None) - .ConfigureAwait(false); + var (attachment, stream) = await _attachmentExtractor.GetAttachment( + item, + mediaSourceId, + index, + CancellationToken.None) + .ConfigureAwait(false); - var contentType = string.IsNullOrWhiteSpace(attachment.MimeType) - ? MediaTypeNames.Application.Octet - : attachment.MimeType; + var contentType = string.IsNullOrWhiteSpace(attachment.MimeType) + ? MediaTypeNames.Application.Octet + : attachment.MimeType; - return new FileStreamResult(stream, contentType); - } - catch (ResourceNotFoundException e) - { - return NotFound(e.Message); - } + return new FileStreamResult(stream, contentType); + } + catch (ResourceNotFoundException e) + { + return NotFound(e.Message); } } } diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 64d8fb498..01a319879 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -21,7 +21,6 @@ using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; @@ -32,644 +31,643 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// The videos controller. +/// </summary> +public class VideosController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly IDlnaManager _dlnaManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly IHttpClientFactory _httpClientFactory; + private readonly EncodingHelper _encodingHelper; + + private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; + + /// <summary> + /// Initializes a new instance of the <see cref="VideosController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> + /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + public VideosController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService, + IDlnaManager dlnaManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + IHttpClientFactory httpClientFactory, + EncodingHelper encodingHelper) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + _dlnaManager = dlnaManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _httpClientFactory = httpClientFactory; + _encodingHelper = encodingHelper; + } + /// <summary> - /// The videos controller. + /// Gets additional parts for a video. /// </summary> - public class VideosController : BaseJellyfinApiController + /// <param name="itemId">The item id.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Additional parts returned.</response> + /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the parts.</returns> + [HttpGet("{itemId}/AdditionalParts")] + [Authorize(Policy = Policies.DefaultAuthorization)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly IDlnaManager _dlnaManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IDeviceManager _deviceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly IHttpClientFactory _httpClientFactory; - private readonly EncodingHelper _encodingHelper; - - private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive; - - /// <summary> - /// Initializes a new instance of the <see cref="VideosController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> - /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - public VideosController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService, - IDlnaManager dlnaManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - IHttpClientFactory httpClientFactory, - EncodingHelper encodingHelper) + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + + var item = itemId.Equals(default) + ? (userId is null || userId.Value.Equals(default) + ? _libraryManager.RootFolder + : _libraryManager.GetUserRootFolder()) + : _libraryManager.GetItemById(itemId); + + var dtoOptions = new DtoOptions(); + dtoOptions = dtoOptions.AddClientFields(User); + + BaseItemDto[] items; + if (item is Video video) { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - _dlnaManager = dlnaManager; - _mediaSourceManager = mediaSourceManager; - _serverConfigurationManager = serverConfigurationManager; - _mediaEncoder = mediaEncoder; - _deviceManager = deviceManager; - _transcodingJobHelper = transcodingJobHelper; - _httpClientFactory = httpClientFactory; - _encodingHelper = encodingHelper; + items = video.GetAdditionalParts() + .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) + .ToArray(); } - - /// <summary> - /// Gets additional parts for a video. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Additional parts returned.</response> - /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the parts.</returns> - [HttpGet("{itemId}/AdditionalParts")] - [Authorize(Policy = Policies.DefaultAuthorization)] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) + else { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - - var item = itemId.Equals(default) - ? (userId is null || userId.Value.Equals(default) - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); + items = Array.Empty<BaseItemDto>(); + } - var dtoOptions = new DtoOptions(); - dtoOptions = dtoOptions.AddClientFields(User); + var result = new QueryResult<BaseItemDto>(items); + return result; + } - BaseItemDto[] items; - if (item is Video video) - { - items = video.GetAdditionalParts() - .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video)) - .ToArray(); - } - else - { - items = Array.Empty<BaseItemDto>(); - } + /// <summary> + /// Removes alternate video sources. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="204">Alternate sources deleted.</response> + /// <response code="404">Video not found.</response> + /// <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.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId) + { + var video = (Video)_libraryManager.GetItemById(itemId); - var result = new QueryResult<BaseItemDto>(items); - return result; + if (video is null) + { + return NotFound("The video either does not exist or the id does not belong to a video."); } - /// <summary> - /// Removes alternate video sources. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <response code="204">Alternate sources deleted.</response> - /// <response code="404">Video not found.</response> - /// <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.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId) + if (video.LinkedAlternateVersions.Length == 0) { - var video = (Video)_libraryManager.GetItemById(itemId); + video = (Video)_libraryManager.GetItemById(video.PrimaryVersionId); + } - if (video is null) - { - return NotFound("The video either does not exist or the id does not belong to a video."); - } + foreach (var link in video.GetLinkedAlternateVersions()) + { + link.SetPrimaryVersionId(null); + link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - if (video.LinkedAlternateVersions.Length == 0) - { - video = (Video)_libraryManager.GetItemById(video.PrimaryVersionId); - } + await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } - foreach (var link in video.GetLinkedAlternateVersions()) - { - link.SetPrimaryVersionId(null); - link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + video.SetPrimaryVersionId(null); + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - } + return NoContent(); + } - video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - video.SetPrimaryVersionId(null); - await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + /// <summary> + /// Merges videos into a single record. + /// </summary> + /// <param name="ids">Item id list. This allows multiple, comma delimited.</param> + /// <response code="204">Videos merged.</response> + /// <response code="400">Supply at least 2 video ids.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns> + [HttpPost("MergeVersions")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + { + var items = ids + .Select(i => _libraryManager.GetItemById(i)) + .OfType<Video>() + .OrderBy(i => i.Id) + .ToList(); - return NoContent(); + if (items.Count < 2) + { + return BadRequest("Please supply at least two videos to merge."); } - /// <summary> - /// Merges videos into a single record. - /// </summary> - /// <param name="ids">Item id list. This allows multiple, comma delimited.</param> - /// <response code="204">Videos merged.</response> - /// <response code="400">Supply at least 2 video ids.</response> - /// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns> - [HttpPost("MergeVersions")] - [Authorize(Policy = Policies.RequiresElevation)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) + var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId)); + if (primaryVersion is null) { - var items = ids - .Select(i => _libraryManager.GetItemById(i)) - .OfType<Video>() - .OrderBy(i => i.Id) - .ToList(); - - if (items.Count < 2) - { - return BadRequest("Please supply at least two videos to merge."); - } - - var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId)); - if (primaryVersion is null) - { - primaryVersion = items - .OrderBy(i => + primaryVersion = items + .OrderBy(i => + { + if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile) { - if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile) - { - return 1; - } - - return 0; - }) - .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0) - .First(); - } + return 1; + } - var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList(); + return 0; + }) + .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0) + .First(); + } - foreach (var item in items.Where(i => !i.Id.Equals(primaryVersion.Id))) - { - item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); + var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList(); - await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + foreach (var item in items.Where(i => !i.Id.Equals(primaryVersion.Id))) + { + item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase))) - { - alternateVersionsOfPrimary.Add(new LinkedChild - { - Path = item.Path, - ItemId = item.Id - }); - } + await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - foreach (var linkedItem in item.LinkedAlternateVersions) + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase))) + { + alternateVersionsOfPrimary.Add(new LinkedChild { - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) - { - alternateVersionsOfPrimary.Add(linkedItem); - } - } + Path = item.Path, + ItemId = item.Id + }); + } - if (item.LinkedAlternateVersions.Length > 0) + foreach (var linkedItem in item.LinkedAlternateVersions) + { + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) { - item.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + alternateVersionsOfPrimary.Add(linkedItem); } } - primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray(); - await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - return NoContent(); + if (item.LinkedAlternateVersions.Length > 0) + { + item.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } } - /// <summary> - /// Gets a video stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> - /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Video stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("{itemId}/stream")] - [HttpHead("{itemId}/stream", Name = "HeadVideoStream")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesVideoFile] - public async Task<ActionResult> GetVideoStream( - [FromRoute, Required] Guid itemId, - [FromQuery] string? container, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray(); + await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Gets a video stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/stream")] + [HttpHead("{itemId}/stream", Name = "HeadVideoStream")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesVideoFile] + public async Task<ActionResult> GetVideoStream( + [FromRoute, Required] Guid itemId, + [FromQuery] string? container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); + var streamingRequest = new VideoRequestDto { - var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - var streamingRequest = new VideoRequestDto - { - Id = itemId, - Container = container, - Static = @static ?? false, - Params = @params, - Tag = tag, - DeviceProfileId = deviceProfileId, - PlaySessionId = playSessionId, - SegmentContainer = segmentContainer, - SegmentLength = segmentLength, - MinSegments = minSegments, - MediaSourceId = mediaSourceId, - DeviceId = deviceId, - AudioCodec = audioCodec, - EnableAutoStreamCopy = enableAutoStreamCopy ?? true, - AllowAudioStreamCopy = allowAudioStreamCopy ?? true, - AllowVideoStreamCopy = allowVideoStreamCopy ?? true, - BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, - AudioSampleRate = audioSampleRate, - MaxAudioChannels = maxAudioChannels, - AudioBitRate = audioBitRate, - MaxAudioBitDepth = maxAudioBitDepth, - AudioChannels = audioChannels, - Profile = profile, - Level = level, - Framerate = framerate, - MaxFramerate = maxFramerate, - CopyTimestamps = copyTimestamps ?? false, - StartTimeTicks = startTimeTicks, - Width = width, - Height = height, - MaxWidth = maxWidth, - MaxHeight = maxHeight, - VideoBitRate = videoBitRate, - SubtitleStreamIndex = subtitleStreamIndex, - SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, - MaxRefFrames = maxRefFrames, - MaxVideoBitDepth = maxVideoBitDepth, - RequireAvc = requireAvc ?? false, - DeInterlace = deInterlace ?? false, - RequireNonAnamorphic = requireNonAnamorphic ?? false, - TranscodingMaxAudioChannels = transcodingMaxAudioChannels, - CpuCoreLimit = cpuCoreLimit, - LiveStreamId = liveStreamId, - EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, - VideoCodec = videoCodec, - SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodeReasons, - AudioStreamIndex = audioStreamIndex, - VideoStreamIndex = videoStreamIndex, - Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions - }; - - var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - _transcodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - if (@static.HasValue && @static.Value && state.DirectStreamProvider is not null) - { - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager); + Id = itemId, + Container = container, + Static = @static ?? false, + Params = @params, + Tag = tag, + DeviceProfileId = deviceProfileId, + PlaySessionId = playSessionId, + SegmentContainer = segmentContainer, + SegmentLength = segmentLength, + MinSegments = minSegments, + MediaSourceId = mediaSourceId, + DeviceId = deviceId, + AudioCodec = audioCodec, + EnableAutoStreamCopy = enableAutoStreamCopy ?? true, + AllowAudioStreamCopy = allowAudioStreamCopy ?? true, + AllowVideoStreamCopy = allowVideoStreamCopy ?? true, + BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false, + AudioSampleRate = audioSampleRate, + MaxAudioChannels = maxAudioChannels, + AudioBitRate = audioBitRate, + MaxAudioBitDepth = maxAudioBitDepth, + AudioChannels = audioChannels, + Profile = profile, + Level = level, + Framerate = framerate, + MaxFramerate = maxFramerate, + CopyTimestamps = copyTimestamps ?? false, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + MaxWidth = maxWidth, + MaxHeight = maxHeight, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod ?? SubtitleDeliveryMethod.Encode, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? false, + DeInterlace = deInterlace ?? false, + RequireNonAnamorphic = requireNonAnamorphic ?? false, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? false, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Streaming, + StreamOptions = streamOptions + }; + + var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + _transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); - var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); - if (liveStreamInfo is null) - { - return NotFound(); - } + if (@static.HasValue && @static.Value && state.DirectStreamProvider is not null) + { + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager); - var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); - // TODO (moved from MediaBrowser.Api): Don't hardcode contentType - return File(liveStream, MimeTypes.GetMimeType("file.ts")); + var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); + if (liveStreamInfo is null) + { + return NotFound(); } - // Static remote stream - if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http) - { - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager); + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); + // TODO (moved from MediaBrowser.Api): Don't hardcode contentType + return File(liveStream, MimeTypes.GetMimeType("file.ts")); + } - var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); - return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, HttpContext).ConfigureAwait(false); - } + // Static remote stream + if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http) + { + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, state.Request.StartTimeTicks, Request, _dlnaManager); - if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File) - { - return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically"); - } + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, HttpContext).ConfigureAwait(false); + } - var outputPath = state.OutputFilePath; - var outputPathExists = System.IO.File.Exists(outputPath); + if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File) + { + return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically"); + } - var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); - var isTranscodeCached = outputPathExists && transcodingJob is not null; + var outputPath = state.OutputFilePath; + var outputPathExists = System.IO.File.Exists(outputPath); - StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, state.Request.StartTimeTicks, Request, _dlnaManager); + var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); + var isTranscodeCached = outputPathExists && transcodingJob is not null; - // Static stream - if (@static.HasValue && @static.Value) - { - var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, state.Request.StartTimeTicks, Request, _dlnaManager); - if (state.MediaSource.IsInfiniteStream) - { - var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); - return File(liveStream, contentType); - } + // Static stream + if (@static.HasValue && @static.Value) + { + var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); - return FileStreamResponseHelpers.GetStaticFileResult( - state.MediaPath, - contentType); + if (state.MediaSource.IsInfiniteStream) + { + var liveStream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); + return File(liveStream, contentType); } - // Need to start ffmpeg (because media can't be returned directly) - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast"); - return await FileStreamResponseHelpers.GetTranscodedFile( - state, - isHeadRequest, - HttpContext, - _transcodingJobHelper, - ffmpegCommandLineArguments, - _transcodingJobType, - cancellationTokenSource).ConfigureAwait(false); + return FileStreamResponseHelpers.GetStaticFileResult( + state.MediaPath, + contentType); } - /// <summary> - /// Gets a video stream. - /// </summary> - /// <param name="itemId">The item id.</param> - /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> - /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> - /// <param name="params">The streaming parameters.</param> - /// <param name="tag">The tag.</param> - /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> - /// <param name="playSessionId">The play session id.</param> - /// <param name="segmentContainer">The segment container.</param> - /// <param name="segmentLength">The segment length.</param> - /// <param name="minSegments">The minimum number of segments.</param> - /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> - /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> - /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> - /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> - /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> - /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> - /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> - /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> - /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> - /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> - /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> - /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> - /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> - /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> - /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> - /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> - /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> - /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> - /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> - /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> - /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> - /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> - /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> - /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> - /// <param name="maxRefFrames">Optional.</param> - /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> - /// <param name="requireAvc">Optional. Whether to require avc.</param> - /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> - /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> - /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> - /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> - /// <param name="liveStreamId">The live stream id.</param> - /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> - /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> - /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> - /// <param name="transcodeReasons">Optional. The transcoding reason.</param> - /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> - /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> - /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> - /// <param name="streamOptions">Optional. The streaming options.</param> - /// <response code="200">Video stream returned.</response> - /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> - [HttpGet("{itemId}/stream.{container}")] - [HttpHead("{itemId}/stream.{container}", Name = "HeadVideoStreamByContainer")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesVideoFile] - public Task<ActionResult> GetVideoStreamByContainer( - [FromRoute, Required] Guid itemId, - [FromRoute, Required] string container, - [FromQuery] bool? @static, - [FromQuery] string? @params, - [FromQuery] string? tag, - [FromQuery] string? deviceProfileId, - [FromQuery] string? playSessionId, - [FromQuery] string? segmentContainer, - [FromQuery] int? segmentLength, - [FromQuery] int? minSegments, - [FromQuery] string? mediaSourceId, - [FromQuery] string? deviceId, - [FromQuery] string? audioCodec, - [FromQuery] bool? enableAutoStreamCopy, - [FromQuery] bool? allowVideoStreamCopy, - [FromQuery] bool? allowAudioStreamCopy, - [FromQuery] bool? breakOnNonKeyFrames, - [FromQuery] int? audioSampleRate, - [FromQuery] int? maxAudioBitDepth, - [FromQuery] int? audioBitRate, - [FromQuery] int? audioChannels, - [FromQuery] int? maxAudioChannels, - [FromQuery] string? profile, - [FromQuery] string? level, - [FromQuery] float? framerate, - [FromQuery] float? maxFramerate, - [FromQuery] bool? copyTimestamps, - [FromQuery] long? startTimeTicks, - [FromQuery] int? width, - [FromQuery] int? height, - [FromQuery] int? maxWidth, - [FromQuery] int? maxHeight, - [FromQuery] int? videoBitRate, - [FromQuery] int? subtitleStreamIndex, - [FromQuery] SubtitleDeliveryMethod? subtitleMethod, - [FromQuery] int? maxRefFrames, - [FromQuery] int? maxVideoBitDepth, - [FromQuery] bool? requireAvc, - [FromQuery] bool? deInterlace, - [FromQuery] bool? requireNonAnamorphic, - [FromQuery] int? transcodingMaxAudioChannels, - [FromQuery] int? cpuCoreLimit, - [FromQuery] string? liveStreamId, - [FromQuery] bool? enableMpegtsM2TsMode, - [FromQuery] string? videoCodec, - [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodeReasons, - [FromQuery] int? audioStreamIndex, - [FromQuery] int? videoStreamIndex, - [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) - { - return GetVideoStream( - itemId, - container, - @static, - @params, - tag, - deviceProfileId, - playSessionId, - segmentContainer, - segmentLength, - minSegments, - mediaSourceId, - deviceId, - audioCodec, - enableAutoStreamCopy, - allowVideoStreamCopy, - allowAudioStreamCopy, - breakOnNonKeyFrames, - audioSampleRate, - maxAudioBitDepth, - audioBitRate, - audioChannels, - maxAudioChannels, - profile, - level, - framerate, - maxFramerate, - copyTimestamps, - startTimeTicks, - width, - height, - maxWidth, - maxHeight, - videoBitRate, - subtitleStreamIndex, - subtitleMethod, - maxRefFrames, - maxVideoBitDepth, - requireAvc, - deInterlace, - requireNonAnamorphic, - transcodingMaxAudioChannels, - cpuCoreLimit, - liveStreamId, - enableMpegtsM2TsMode, - videoCodec, - subtitleCodec, - transcodeReasons, - audioStreamIndex, - videoStreamIndex, - context, - streamOptions); - } + // Need to start ffmpeg (because media can't be returned directly) + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast"); + return await FileStreamResponseHelpers.GetTranscodedFile( + state, + isHeadRequest, + HttpContext, + _transcodingJobHelper, + ffmpegCommandLineArguments, + _transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); + } + + /// <summary> + /// Gets a video stream. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param> + /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param> + /// <param name="params">The streaming parameters.</param> + /// <param name="tag">The tag.</param> + /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param> + /// <param name="playSessionId">The play session id.</param> + /// <param name="segmentContainer">The segment container.</param> + /// <param name="segmentLength">The segment length.</param> + /// <param name="minSegments">The minimum number of segments.</param> + /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param> + /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param> + /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param> + /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param> + /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param> + /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param> + /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> + /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param> + /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> + /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param> + /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param> + /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param> + /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param> + /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param> + /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param> + /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param> + /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param> + /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param> + /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param> + /// <param name="maxWidth">Optional. The maximum horizontal resolution of the encoded video.</param> + /// <param name="maxHeight">Optional. The maximum vertical resolution of the encoded video.</param> + /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param> + /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param> + /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param> + /// <param name="maxRefFrames">Optional.</param> + /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param> + /// <param name="requireAvc">Optional. Whether to require avc.</param> + /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param> + /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param> + /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param> + /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param> + /// <param name="liveStreamId">The live stream id.</param> + /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param> + /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vp8, vp9, vpx (deprecated), wmv.</param> + /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param> + /// <param name="transcodeReasons">Optional. The transcoding reason.</param> + /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param> + /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> + /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> + /// <param name="streamOptions">Optional. The streaming options.</param> + /// <response code="200">Video stream returned.</response> + /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> + [HttpGet("{itemId}/stream.{container}")] + [HttpHead("{itemId}/stream.{container}", Name = "HeadVideoStreamByContainer")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesVideoFile] + public Task<ActionResult> GetVideoStreamByContainer( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string container, + [FromQuery] bool? @static, + [FromQuery] string? @params, + [FromQuery] string? tag, + [FromQuery] string? deviceProfileId, + [FromQuery] string? playSessionId, + [FromQuery] string? segmentContainer, + [FromQuery] int? segmentLength, + [FromQuery] int? minSegments, + [FromQuery] string? mediaSourceId, + [FromQuery] string? deviceId, + [FromQuery] string? audioCodec, + [FromQuery] bool? enableAutoStreamCopy, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromQuery] bool? breakOnNonKeyFrames, + [FromQuery] int? audioSampleRate, + [FromQuery] int? maxAudioBitDepth, + [FromQuery] int? audioBitRate, + [FromQuery] int? audioChannels, + [FromQuery] int? maxAudioChannels, + [FromQuery] string? profile, + [FromQuery] string? level, + [FromQuery] float? framerate, + [FromQuery] float? maxFramerate, + [FromQuery] bool? copyTimestamps, + [FromQuery] long? startTimeTicks, + [FromQuery] int? width, + [FromQuery] int? height, + [FromQuery] int? maxWidth, + [FromQuery] int? maxHeight, + [FromQuery] int? videoBitRate, + [FromQuery] int? subtitleStreamIndex, + [FromQuery] SubtitleDeliveryMethod? subtitleMethod, + [FromQuery] int? maxRefFrames, + [FromQuery] int? maxVideoBitDepth, + [FromQuery] bool? requireAvc, + [FromQuery] bool? deInterlace, + [FromQuery] bool? requireNonAnamorphic, + [FromQuery] int? transcodingMaxAudioChannels, + [FromQuery] int? cpuCoreLimit, + [FromQuery] string? liveStreamId, + [FromQuery] bool? enableMpegtsM2TsMode, + [FromQuery] string? videoCodec, + [FromQuery] string? subtitleCodec, + [FromQuery] string? transcodeReasons, + [FromQuery] int? audioStreamIndex, + [FromQuery] int? videoStreamIndex, + [FromQuery] EncodingContext? context, + [FromQuery] Dictionary<string, string> streamOptions) + { + return GetVideoStream( + itemId, + container, + @static, + @params, + tag, + deviceProfileId, + playSessionId, + segmentContainer, + segmentLength, + minSegments, + mediaSourceId, + deviceId, + audioCodec, + enableAutoStreamCopy, + allowVideoStreamCopy, + allowAudioStreamCopy, + breakOnNonKeyFrames, + audioSampleRate, + maxAudioBitDepth, + audioBitRate, + audioChannels, + maxAudioChannels, + profile, + level, + framerate, + maxFramerate, + copyTimestamps, + startTimeTicks, + width, + height, + maxWidth, + maxHeight, + videoBitRate, + subtitleStreamIndex, + subtitleMethod, + maxRefFrames, + maxVideoBitDepth, + requireAvc, + deInterlace, + requireNonAnamorphic, + transcodingMaxAudioChannels, + cpuCoreLimit, + liveStreamId, + enableMpegtsM2TsMode, + videoCodec, + subtitleCodec, + transcodeReasons, + audioStreamIndex, + videoStreamIndex, + context, + streamOptions); } } diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs index cd85ba221..2e5fdc146 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -19,208 +19,207 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Controllers +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Years controller. +/// </summary> +[Authorize(Policy = Policies.DefaultAuthorization)] +public class YearsController : BaseJellyfinApiController { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + /// <summary> - /// Years controller. + /// Initializes a new instance of the <see cref="YearsController"/> class. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] - public class YearsController : BaseJellyfinApiController + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> + public YearsController( + ILibraryManager libraryManager, + IUserManager userManager, + IDtoService dtoService) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - - /// <summary> - /// Initializes a new instance of the <see cref="YearsController"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> - public YearsController( - ILibraryManager libraryManager, - IUserManager userManager, - IDtoService dtoService) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dtoService = dtoService; - } + _libraryManager = libraryManager; + _userManager = userManager; + _dtoService = dtoService; + } - /// <summary> - /// Get years. - /// </summary> - /// <param name="startIndex">Skips over a given number of items within the results. Use for paging.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> - /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> - /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> - /// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param> - /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> - /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> - /// <param name="enableUserData">Optional. Include user data.</param> - /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> - /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> - /// <param name="userId">User Id.</param> - /// <param name="recursive">Search recursively.</param> - /// <param name="enableImages">Optional. Include image information in output.</param> - /// <response code="200">Year query returned.</response> - /// <returns> A <see cref="QueryResult{BaseItemDto}"/> containing the year result.</returns> - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<QueryResult<BaseItemDto>> GetYears( - [FromQuery] int? startIndex, - [FromQuery] int? limit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, - [FromQuery] Guid? parentId, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, - [FromQuery] bool? enableUserData, - [FromQuery] int? imageTypeLimit, - [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] Guid? userId, - [FromQuery] bool recursive = true, - [FromQuery] bool? enableImages = true) - { - var dtoOptions = new DtoOptions { Fields = fields } - .AddClientFields(User) - .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + /// <summary> + /// Get years. + /// </summary> + /// <param name="startIndex">Skips over a given number of items within the results. Use for paging.</param> + /// <param name="limit">Optional. The maximum number of records to return.</param> + /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> + /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> + /// <param name="excludeItemTypes">Optional. If specified, results will be excluded based on item type. This allows multiple, comma delimited.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param> + /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param> + /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param> + /// <param name="enableUserData">Optional. Include user data.</param> + /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> + /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> + /// <param name="userId">User Id.</param> + /// <param name="recursive">Search recursively.</param> + /// <param name="enableImages">Optional. Include image information in output.</param> + /// <response code="200">Year query returned.</response> + /// <returns> A <see cref="QueryResult{BaseItemDto}"/> containing the year result.</returns> + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult<QueryResult<BaseItemDto>> GetYears( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[] sortOrder, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, + [FromQuery] bool? enableUserData, + [FromQuery] int? imageTypeLimit, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, + [FromQuery] Guid? userId, + [FromQuery] bool recursive = true, + [FromQuery] bool? enableImages = true) + { + var dtoOptions = new DtoOptions { Fields = fields } + .AddClientFields(User) + .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - User? user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); + User? user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); - var query = new InternalItemsQuery(user) - { - ExcludeItemTypes = excludeItemTypes, - IncludeItemTypes = includeItemTypes, - MediaTypes = mediaTypes, - DtoOptions = dtoOptions - }; + var query = new InternalItemsQuery(user) + { + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, + DtoOptions = dtoOptions + }; + + bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes); - bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes); + IList<BaseItem> items; + if (parentItem.IsFolder) + { + var folder = (Folder)parentItem; - IList<BaseItem> items; - if (parentItem.IsFolder) + if (userId.Equals(default)) { - var folder = (Folder)parentItem; - - if (userId.Equals(default)) - { - items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList(); - } - else - { - items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList(); - } + items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList(); } else { - items = new[] { parentItem }.Where(Filter).ToList(); + items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList(); } + } + else + { + items = new[] { parentItem }.Where(Filter).ToList(); + } - var extractedItems = GetAllItems(items); + var extractedItems = GetAllItems(items); - var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder)); + var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder)); - var ibnItemsArray = filteredItems.ToList(); + var ibnItemsArray = filteredItems.ToList(); - IEnumerable<BaseItem> ibnItems = ibnItemsArray; + IEnumerable<BaseItem> ibnItems = ibnItemsArray; - if (startIndex.HasValue || limit.HasValue) + if (startIndex.HasValue || limit.HasValue) + { + if (startIndex.HasValue) { - if (startIndex.HasValue) - { - ibnItems = ibnItems.Skip(startIndex.Value); - } - - if (limit.HasValue) - { - ibnItems = ibnItems.Take(limit.Value); - } + ibnItems = ibnItems.Skip(startIndex.Value); } - var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>())); - - var dtos = tuples.Select(i => _dtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user)); - - var result = new QueryResult<BaseItemDto>( - startIndex, - ibnItemsArray.Count, - dtos.Where(i => i is not null).ToArray()); - return result; - } - - /// <summary> - /// Gets a year. - /// </summary> - /// <param name="year">The year.</param> - /// <param name="userId">Optional. Filter by user id, and attach user data.</param> - /// <response code="200">Year returned.</response> - /// <response code="404">Year not found.</response> - /// <returns> - /// An <see cref="OkResult"/> containing the year, - /// or a <see cref="NotFoundResult"/> if year not found. - /// </returns> - [HttpGet("{year}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<BaseItemDto> GetYear([FromRoute, Required] int year, [FromQuery] Guid? userId) - { - var item = _libraryManager.GetYear(year); - if (item is null) + if (limit.HasValue) { - return NotFound(); + ibnItems = ibnItems.Take(limit.Value); } + } - var dtoOptions = new DtoOptions() - .AddClientFields(User); + var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>())); - if (userId.HasValue && !userId.Value.Equals(default)) - { - var user = _userManager.GetUserById(userId.Value); - return _dtoService.GetBaseItemDto(item, dtoOptions, user); - } + var dtos = tuples.Select(i => _dtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user)); + + var result = new QueryResult<BaseItemDto>( + startIndex, + ibnItemsArray.Count, + dtos.Where(i => i is not null).ToArray()); + return result; + } - return _dtoService.GetBaseItemDto(item, dtoOptions); + /// <summary> + /// Gets a year. + /// </summary> + /// <param name="year">The year.</param> + /// <param name="userId">Optional. Filter by user id, and attach user data.</param> + /// <response code="200">Year returned.</response> + /// <response code="404">Year not found.</response> + /// <returns> + /// An <see cref="OkResult"/> containing the year, + /// or a <see cref="NotFoundResult"/> if year not found. + /// </returns> + [HttpGet("{year}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<BaseItemDto> GetYear([FromRoute, Required] int year, [FromQuery] Guid? userId) + { + var item = _libraryManager.GetYear(year); + if (item is null) + { + return NotFound(); } - private bool FilterItem(BaseItem f, IReadOnlyCollection<BaseItemKind> excludeItemTypes, IReadOnlyCollection<BaseItemKind> includeItemTypes, IReadOnlyCollection<string> mediaTypes) + var dtoOptions = new DtoOptions() + .AddClientFields(User); + + if (userId.HasValue && !userId.Value.Equals(default)) { - var baseItemKind = f.GetBaseItemKind(); - // Exclude item types - if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(baseItemKind)) - { - return false; - } + var user = _userManager.GetUserById(userId.Value); + return _dtoService.GetBaseItemDto(item, dtoOptions, user); + } - // Include item types - if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(baseItemKind)) - { - return false; - } + return _dtoService.GetBaseItemDto(item, dtoOptions); + } - // Include MediaTypes - if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) - { - return false; - } + private bool FilterItem(BaseItem f, IReadOnlyCollection<BaseItemKind> excludeItemTypes, IReadOnlyCollection<BaseItemKind> includeItemTypes, IReadOnlyCollection<string> mediaTypes) + { + var baseItemKind = f.GetBaseItemKind(); + // Exclude item types + if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(baseItemKind)) + { + return false; + } - return true; + // Include item types + if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(baseItemKind)) + { + return false; } - private IEnumerable<BaseItem> GetAllItems(IEnumerable<BaseItem> items) + // Include MediaTypes + if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { - return items - .Select(i => i.ProductionYear ?? 0) - .Where(i => i > 0) - .Distinct() - .Select(year => _libraryManager.GetYear(year)); + return false; } + + return true; + } + + private IEnumerable<BaseItem> GetAllItems(IEnumerable<BaseItem> items) + { + return items + .Select(i => i.ProductionYear ?? 0) + .Where(i => i > 0) + .Distinct() + .Select(year => _libraryManager.GetYear(year)); } } diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index 9e784f7c4..2d7a56d91 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -5,112 +5,110 @@ using Jellyfin.Extensions; using MediaBrowser.Controller.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; -using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Extensions +namespace Jellyfin.Api.Extensions; + +/// <summary> +/// Dto Extensions. +/// </summary> +public static class DtoExtensions { /// <summary> - /// Dto Extensions. + /// Add additional fields depending on client. /// </summary> - public static class DtoExtensions + /// <remarks> + /// Use in place of GetDtoOptions. + /// Legacy order: 2. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="user">Current claims principal.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddClientFields( + this DtoOptions dtoOptions, ClaimsPrincipal user) { - /// <summary> - /// Add additional fields depending on client. - /// </summary> - /// <remarks> - /// Use in place of GetDtoOptions. - /// Legacy order: 2. - /// </remarks> - /// <param name="dtoOptions">DtoOptions object.</param> - /// <param name="user">Current claims principal.</param> - /// <returns>Modified DtoOptions object.</returns> - internal static DtoOptions AddClientFields( - this DtoOptions dtoOptions, ClaimsPrincipal user) - { - dtoOptions.Fields ??= Array.Empty<ItemFields>(); + dtoOptions.Fields ??= Array.Empty<ItemFields>(); - string? client = user.GetClient(); + string? client = user.GetClient(); - // No client in claim - if (string.IsNullOrEmpty(client)) - { - return dtoOptions; - } + // No client in claim + if (string.IsNullOrEmpty(client)) + { + return dtoOptions; + } - if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount)) + if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount)) + { + if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1) { - if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1) - { - int oldLen = dtoOptions.Fields.Count; - var arr = new ItemFields[oldLen + 1]; - dtoOptions.Fields.CopyTo(arr, 0); - arr[oldLen] = ItemFields.RecursiveItemCount; - dtoOptions.Fields = arr; - } + int oldLen = dtoOptions.Fields.Count; + var arr = new ItemFields[oldLen + 1]; + dtoOptions.Fields.CopyTo(arr, 0); + arr[oldLen] = ItemFields.RecursiveItemCount; + dtoOptions.Fields = arr; } + } - if (!dtoOptions.ContainsField(ItemFields.ChildCount)) + if (!dtoOptions.ContainsField(ItemFields.ChildCount)) + { + if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 || + client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1) { - if (client.IndexOf("kodi", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("wmc", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("media center", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("classic", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("samsung", StringComparison.OrdinalIgnoreCase) != -1 || - client.IndexOf("androidtv", StringComparison.OrdinalIgnoreCase) != -1) - { - int oldLen = dtoOptions.Fields.Count; - var arr = new ItemFields[oldLen + 1]; - dtoOptions.Fields.CopyTo(arr, 0); - arr[oldLen] = ItemFields.ChildCount; - dtoOptions.Fields = arr; - } + int oldLen = dtoOptions.Fields.Count; + var arr = new ItemFields[oldLen + 1]; + dtoOptions.Fields.CopyTo(arr, 0); + arr[oldLen] = ItemFields.ChildCount; + dtoOptions.Fields = arr; } - - return dtoOptions; } - /// <summary> - /// Add additional DtoOptions. - /// </summary> - /// <remarks> - /// Converted from IHasDtoOptions. - /// Legacy order: 3. - /// </remarks> - /// <param name="dtoOptions">DtoOptions object.</param> - /// <param name="enableImages">Enable images.</param> - /// <param name="enableUserData">Enable user data.</param> - /// <param name="imageTypeLimit">Image type limit.</param> - /// <param name="enableImageTypes">Enable image types.</param> - /// <returns>Modified DtoOptions object.</returns> - internal static DtoOptions AddAdditionalDtoOptions( - this DtoOptions dtoOptions, - bool? enableImages, - bool? enableUserData, - int? imageTypeLimit, - IReadOnlyList<ImageType> enableImageTypes) - { - dtoOptions.EnableImages = enableImages ?? true; + return dtoOptions; + } - if (imageTypeLimit.HasValue) - { - dtoOptions.ImageTypeLimit = imageTypeLimit.Value; - } + /// <summary> + /// Add additional DtoOptions. + /// </summary> + /// <remarks> + /// Converted from IHasDtoOptions. + /// Legacy order: 3. + /// </remarks> + /// <param name="dtoOptions">DtoOptions object.</param> + /// <param name="enableImages">Enable images.</param> + /// <param name="enableUserData">Enable user data.</param> + /// <param name="imageTypeLimit">Image type limit.</param> + /// <param name="enableImageTypes">Enable image types.</param> + /// <returns>Modified DtoOptions object.</returns> + internal static DtoOptions AddAdditionalDtoOptions( + this DtoOptions dtoOptions, + bool? enableImages, + bool? enableUserData, + int? imageTypeLimit, + IReadOnlyList<ImageType> enableImageTypes) + { + dtoOptions.EnableImages = enableImages ?? true; - if (enableUserData.HasValue) - { - dtoOptions.EnableUserData = enableUserData.Value; - } + if (imageTypeLimit.HasValue) + { + dtoOptions.ImageTypeLimit = imageTypeLimit.Value; + } - if (enableImageTypes.Count != 0) - { - dtoOptions.ImageTypes = enableImageTypes; - } + if (enableUserData.HasValue) + { + dtoOptions.EnableUserData = enableUserData.Value; + } - return dtoOptions; + if (enableImageTypes.Count != 0) + { + dtoOptions.ImageTypes = enableImageTypes; } + + return dtoOptions; } } diff --git a/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs index 8f1f5dd94..96b29b1cb 100644 --- a/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs +++ b/Jellyfin.Api/Formatters/CamelCaseJsonProfileFormatter.cs @@ -2,20 +2,19 @@ using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Formatters +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Camel Case Json Profile Formatter. +/// </summary> +public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter { /// <summary> - /// Camel Case Json Profile Formatter. + /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class. /// </summary> - public class CamelCaseJsonProfileFormatter : SystemTextJsonOutputFormatter + public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCaseOptions) { - /// <summary> - /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class. - /// </summary> - public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCaseOptions) - { - SupportedMediaTypes.Clear(); - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.CamelCaseMediaType)); - } + SupportedMediaTypes.Clear(); + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.CamelCaseMediaType)); } } diff --git a/Jellyfin.Api/Formatters/CssOutputFormatter.cs b/Jellyfin.Api/Formatters/CssOutputFormatter.cs index e88c8ad1b..0a3891138 100644 --- a/Jellyfin.Api/Formatters/CssOutputFormatter.cs +++ b/Jellyfin.Api/Formatters/CssOutputFormatter.cs @@ -3,34 +3,33 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; -namespace Jellyfin.Api.Formatters +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Css output formatter. +/// </summary> +public class CssOutputFormatter : TextOutputFormatter { /// <summary> - /// Css output formatter. + /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class. /// </summary> - public class CssOutputFormatter : TextOutputFormatter + public CssOutputFormatter() { - /// <summary> - /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class. - /// </summary> - public CssOutputFormatter() - { - SupportedMediaTypes.Add("text/css"); + SupportedMediaTypes.Add("text/css"); - SupportedEncodings.Add(Encoding.UTF8); - SupportedEncodings.Add(Encoding.Unicode); - } + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } - /// <summary> - /// Write context object to stream. - /// </summary> - /// <param name="context">Writer context.</param> - /// <param name="selectedEncoding">Unused. Writer encoding.</param> - /// <returns>Write stream task.</returns> - public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) - { - var stringResponse = context.Object?.ToString(); - return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); - } + /// <summary> + /// Write context object to stream. + /// </summary> + /// <param name="context">Writer context.</param> + /// <param name="selectedEncoding">Unused. Writer encoding.</param> + /// <returns>Write stream task.</returns> + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + var stringResponse = context.Object?.ToString(); + return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); } } diff --git a/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs index 5d77dbf4c..b5b575278 100644 --- a/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs +++ b/Jellyfin.Api/Formatters/PascalCaseJsonProfileFormatter.cs @@ -3,22 +3,21 @@ using Jellyfin.Extensions.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Formatters +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Pascal Case Json Profile Formatter. +/// </summary> +public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter { /// <summary> - /// Pascal Case Json Profile Formatter. + /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class. /// </summary> - public class PascalCaseJsonProfileFormatter : SystemTextJsonOutputFormatter + public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCaseOptions) { - /// <summary> - /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class. - /// </summary> - public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCaseOptions) - { - SupportedMediaTypes.Clear(); - // Add application/json for default formatter - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json)); - SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.PascalCaseMediaType)); - } + SupportedMediaTypes.Clear(); + // Add application/json for default formatter + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json)); + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse(JsonDefaults.PascalCaseMediaType)); } } diff --git a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs index df8b1650b..d5dea0f09 100644 --- a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs +++ b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs @@ -4,30 +4,29 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Formatters; -namespace Jellyfin.Api.Formatters +namespace Jellyfin.Api.Formatters; + +/// <summary> +/// Xml output formatter. +/// </summary> +public class XmlOutputFormatter : TextOutputFormatter { /// <summary> - /// Xml output formatter. + /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class. /// </summary> - public class XmlOutputFormatter : TextOutputFormatter + public XmlOutputFormatter() { - /// <summary> - /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class. - /// </summary> - public XmlOutputFormatter() - { - SupportedMediaTypes.Clear(); - SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); + SupportedMediaTypes.Clear(); + SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); - SupportedEncodings.Add(Encoding.UTF8); - SupportedEncodings.Add(Encoding.Unicode); - } + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } - /// <inheritdoc /> - public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) - { - var stringResponse = context.Object?.ToString(); - return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); - } + /// <inheritdoc /> + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + var stringResponse = context.Object?.ToString(); + return stringResponse is null ? Task.CompletedTask : context.HttpContext.Response.WriteAsync(stringResponse); } } diff --git a/Jellyfin.Api/Helpers/AudioHelper.cs b/Jellyfin.Api/Helpers/AudioHelper.cs index be410ebcd..2b18c389d 100644 --- a/Jellyfin.Api/Helpers/AudioHelper.cs +++ b/Jellyfin.Api/Helpers/AudioHelper.cs @@ -16,165 +16,164 @@ using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Audio helper. +/// </summary> +public class AudioHelper { + private readonly IDlnaManager _dlnaManager; + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly EncodingHelper _encodingHelper; + /// <summary> - /// Audio helper. + /// Initializes a new instance of the <see cref="AudioHelper"/> class. /// </summary> - public class AudioHelper + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param> + /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + public AudioHelper( + IDlnaManager dlnaManager, + IUserManager userManager, + ILibraryManager libraryManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, + EncodingHelper encodingHelper) { - private readonly IDlnaManager _dlnaManager; - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IDeviceManager _deviceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly EncodingHelper _encodingHelper; - - /// <summary> - /// Initializes a new instance of the <see cref="AudioHelper"/> class. - /// </summary> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param> - /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - public AudioHelper( - IDlnaManager dlnaManager, - IUserManager userManager, - ILibraryManager libraryManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - IHttpClientFactory httpClientFactory, - IHttpContextAccessor httpContextAccessor, - EncodingHelper encodingHelper) + _dlnaManager = dlnaManager; + _userManager = userManager; + _libraryManager = libraryManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _httpClientFactory = httpClientFactory; + _httpContextAccessor = httpContextAccessor; + _encodingHelper = encodingHelper; + } + + /// <summary> + /// Get audio stream. + /// </summary> + /// <param name="transcodingJobType">Transcoding job type.</param> + /// <param name="streamingRequest">Streaming controller.Request dto.</param> + /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns> + public async Task<ActionResult> GetAudioStream( + TranscodingJobType transcodingJobType, + StreamingRequestDto streamingRequest) + { + if (_httpContextAccessor.HttpContext is null) { - _dlnaManager = dlnaManager; - _userManager = userManager; - _libraryManager = libraryManager; - _mediaSourceManager = mediaSourceManager; - _serverConfigurationManager = serverConfigurationManager; - _mediaEncoder = mediaEncoder; - _deviceManager = deviceManager; - _transcodingJobHelper = transcodingJobHelper; - _httpClientFactory = httpClientFactory; - _httpContextAccessor = httpContextAccessor; - _encodingHelper = encodingHelper; + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); } - /// <summary> - /// Get audio stream. - /// </summary> - /// <param name="transcodingJobType">Transcoding job type.</param> - /// <param name="streamingRequest">Streaming controller.Request dto.</param> - /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns> - public async Task<ActionResult> GetAudioStream( - TranscodingJobType transcodingJobType, - StreamingRequestDto streamingRequest) - { - if (_httpContextAccessor.HttpContext is null) - { - throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); - } + bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head; - bool isHeadRequest = _httpContextAccessor.HttpContext.Request.Method == System.Net.WebRequestMethods.Http.Head; - - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - - using var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - _httpContextAccessor.HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - transcodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - if (streamingRequest.Static && state.DirectStreamProvider is not null) - { - StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); - var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); - if (liveStreamInfo is null) - { - throw new FileNotFoundException(); - } + using var state = await StreamingHelpers.GetStreamingState( + streamingRequest, + _httpContextAccessor.HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, + transcodingJobType, + cancellationTokenSource.Token) + .ConfigureAwait(false); - var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); - // TODO (moved from MediaBrowser.Api): Don't hardcode contentType - return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file.ts")); - } + if (streamingRequest.Static && state.DirectStreamProvider is not null) + { + StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); - // Static remote stream - if (streamingRequest.Static && state.InputProtocol == MediaProtocol.Http) + var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfo(streamingRequest.LiveStreamId); + if (liveStreamInfo is null) { - StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); - - var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); - return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, _httpContextAccessor.HttpContext).ConfigureAwait(false); + throw new FileNotFoundException(); } - if (streamingRequest.Static && state.InputProtocol != MediaProtocol.File) - { - return new BadRequestObjectResult($"Input protocol {state.InputProtocol} cannot be streamed statically"); - } + var liveStream = new ProgressiveFileStream(liveStreamInfo.GetStream()); + // TODO (moved from MediaBrowser.Api): Don't hardcode contentType + return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file.ts")); + } - var outputPath = state.OutputFilePath; - var outputPathExists = File.Exists(outputPath); + // Static remote stream + if (streamingRequest.Static && state.InputProtocol == MediaProtocol.Http) + { + StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, true, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); - var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); - var isTranscodeCached = outputPathExists && transcodingJob is not null; + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, httpClient, _httpContextAccessor.HttpContext).ConfigureAwait(false); + } - StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, streamingRequest.Static || isTranscodeCached, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); + if (streamingRequest.Static && state.InputProtocol != MediaProtocol.File) + { + return new BadRequestObjectResult($"Input protocol {state.InputProtocol} cannot be streamed statically"); + } - // Static stream - if (streamingRequest.Static) - { - var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + var outputPath = state.OutputFilePath; + var outputPathExists = File.Exists(outputPath); - if (state.MediaSource.IsInfiniteStream) - { - var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); - return new FileStreamResult(stream, contentType); - } + var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); + var isTranscodeCached = outputPathExists && transcodingJob is not null; - return FileStreamResponseHelpers.GetStaticFileResult( - state.MediaPath, - contentType); + StreamingHelpers.AddDlnaHeaders(state, _httpContextAccessor.HttpContext.Response.Headers, streamingRequest.Static || isTranscodeCached, streamingRequest.StartTimeTicks, _httpContextAccessor.HttpContext.Request, _dlnaManager); + + // Static stream + if (streamingRequest.Static) + { + var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + + if (state.MediaSource.IsInfiniteStream) + { + var stream = new ProgressiveFileStream(state.MediaPath, null, _transcodingJobHelper); + return new FileStreamResult(stream, contentType); } - // Need to start ffmpeg (because media can't be returned directly) - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); - return await FileStreamResponseHelpers.GetTranscodedFile( - state, - isHeadRequest, - _httpContextAccessor.HttpContext, - _transcodingJobHelper, - ffmpegCommandLineArguments, - transcodingJobType, - cancellationTokenSource).ConfigureAwait(false); + return FileStreamResponseHelpers.GetStaticFileResult( + state.MediaPath, + contentType); } + + // Need to start ffmpeg (because media can't be returned directly) + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); + return await FileStreamResponseHelpers.GetTranscodedFile( + state, + isHeadRequest, + _httpContextAccessor.HttpContext, + _transcodingJobHelper, + ffmpegCommandLineArguments, + transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index 4a338efff..245239233 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -25,725 +25,724 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Dynamic hls helper. +/// </summary> +public class DynamicHlsHelper { + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IDlnaManager _dlnaManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IDeviceManager _deviceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private readonly INetworkManager _networkManager; + private readonly ILogger<DynamicHlsHelper> _logger; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly EncodingHelper _encodingHelper; + /// <summary> - /// Dynamic hls helper. + /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class. /// </summary> - public class DynamicHlsHelper + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + public DynamicHlsHelper( + ILibraryManager libraryManager, + IUserManager userManager, + IDlnaManager dlnaManager, + IMediaSourceManager mediaSourceManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + INetworkManager networkManager, + ILogger<DynamicHlsHelper> logger, + IHttpContextAccessor httpContextAccessor, + EncodingHelper encodingHelper) + { + _libraryManager = libraryManager; + _userManager = userManager; + _dlnaManager = dlnaManager; + _mediaSourceManager = mediaSourceManager; + _serverConfigurationManager = serverConfigurationManager; + _mediaEncoder = mediaEncoder; + _deviceManager = deviceManager; + _transcodingJobHelper = transcodingJobHelper; + _networkManager = networkManager; + _logger = logger; + _httpContextAccessor = httpContextAccessor; + _encodingHelper = encodingHelper; + } + + /// <summary> + /// Get master hls playlist. + /// </summary> + /// <param name="transcodingJobType">Transcoding job type.</param> + /// <param name="streamingRequest">Streaming request dto.</param> + /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> + /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns> + public async Task<ActionResult> GetMasterHlsPlaylist( + TranscodingJobType transcodingJobType, + StreamingRequestDto streamingRequest, + bool enableAdaptiveBitrateStreaming) + { + var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head; + // CTS lifecycle is managed internally. + var cancellationTokenSource = new CancellationTokenSource(); + return await GetMasterPlaylistInternal( + streamingRequest, + isHeadRequest, + enableAdaptiveBitrateStreaming, + transcodingJobType, + cancellationTokenSource).ConfigureAwait(false); + } + + private async Task<ActionResult> GetMasterPlaylistInternal( + StreamingRequestDto streamingRequest, + bool isHeadRequest, + bool enableAdaptiveBitrateStreaming, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource) { - private readonly ILibraryManager _libraryManager; - private readonly IUserManager _userManager; - private readonly IDlnaManager _dlnaManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IDeviceManager _deviceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly INetworkManager _networkManager; - private readonly ILogger<DynamicHlsHelper> _logger; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly EncodingHelper _encodingHelper; - - /// <summary> - /// Initializes a new instance of the <see cref="DynamicHlsHelper"/> class. - /// </summary> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">Instance of <see cref="TranscodingJobHelper"/>.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsHelper}"/> interface.</param> - /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - public DynamicHlsHelper( - ILibraryManager libraryManager, - IUserManager userManager, - IDlnaManager dlnaManager, - IMediaSourceManager mediaSourceManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - INetworkManager networkManager, - ILogger<DynamicHlsHelper> logger, - IHttpContextAccessor httpContextAccessor, - EncodingHelper encodingHelper) - { - _libraryManager = libraryManager; - _userManager = userManager; - _dlnaManager = dlnaManager; - _mediaSourceManager = mediaSourceManager; - _serverConfigurationManager = serverConfigurationManager; - _mediaEncoder = mediaEncoder; - _deviceManager = deviceManager; - _transcodingJobHelper = transcodingJobHelper; - _networkManager = networkManager; - _logger = logger; - _httpContextAccessor = httpContextAccessor; - _encodingHelper = encodingHelper; - } - - /// <summary> - /// Get master hls playlist. - /// </summary> - /// <param name="transcodingJobType">Transcoding job type.</param> - /// <param name="streamingRequest">Streaming request dto.</param> - /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> - /// <returns>A <see cref="Task"/> containing the resulting <see cref="ActionResult"/>.</returns> - public async Task<ActionResult> GetMasterHlsPlaylist( - TranscodingJobType transcodingJobType, - StreamingRequestDto streamingRequest, - bool enableAdaptiveBitrateStreaming) - { - var isHeadRequest = _httpContextAccessor.HttpContext?.Request.Method == WebRequestMethods.Http.Head; - // CTS lifecycle is managed internally. - var cancellationTokenSource = new CancellationTokenSource(); - return await GetMasterPlaylistInternal( + if (_httpContextAccessor.HttpContext is null) + { + throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); + } + + using var state = await StreamingHelpers.GetStreamingState( streamingRequest, - isHeadRequest, - enableAdaptiveBitrateStreaming, + _httpContextAccessor.HttpContext, + _mediaSourceManager, + _userManager, + _libraryManager, + _serverConfigurationManager, + _mediaEncoder, + _encodingHelper, + _dlnaManager, + _deviceManager, + _transcodingJobHelper, transcodingJobType, - cancellationTokenSource).ConfigureAwait(false); - } + cancellationTokenSource.Token) + .ConfigureAwait(false); - private async Task<ActionResult> GetMasterPlaylistInternal( - StreamingRequestDto streamingRequest, - bool isHeadRequest, - bool enableAdaptiveBitrateStreaming, - TranscodingJobType transcodingJobType, - CancellationTokenSource cancellationTokenSource) + _httpContextAccessor.HttpContext.Response.Headers.Add(HeaderNames.Expires, "0"); + if (isHeadRequest) { - if (_httpContextAccessor.HttpContext is null) - { - throw new ResourceNotFoundException(nameof(_httpContextAccessor.HttpContext)); - } + return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8")); + } - using var state = await StreamingHelpers.GetStreamingState( - streamingRequest, - _httpContextAccessor.HttpContext, - _mediaSourceManager, - _userManager, - _libraryManager, - _serverConfigurationManager, - _mediaEncoder, - _encodingHelper, - _dlnaManager, - _deviceManager, - _transcodingJobHelper, - transcodingJobType, - cancellationTokenSource.Token) - .ConfigureAwait(false); - - _httpContextAccessor.HttpContext.Response.Headers.Add(HeaderNames.Expires, "0"); - if (isHeadRequest) - { - return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8")); - } + var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0); - var totalBitrate = (state.OutputAudioBitrate ?? 0) + (state.OutputVideoBitrate ?? 0); + var builder = new StringBuilder(); - var builder = new StringBuilder(); + builder.AppendLine("#EXTM3U"); - builder.AppendLine("#EXTM3U"); + var isLiveStream = state.IsSegmentedLiveStream; - var isLiveStream = state.IsSegmentedLiveStream; + var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString(); - var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString(); + // from universal audio service + if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer) + && !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase)) + { + queryString += "&SegmentContainer=" + state.Request.SegmentContainer; + } - // from universal audio service - if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer) - && !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase)) - { - queryString += "&SegmentContainer=" + state.Request.SegmentContainer; - } + // from universal audio service + if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) + && !queryString.Contains("TranscodeReasons=", StringComparison.OrdinalIgnoreCase)) + { + queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; + } - // from universal audio service - if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) - && !queryString.Contains("TranscodeReasons=", StringComparison.OrdinalIgnoreCase)) - { - queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; - } + // Main stream + var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; - // Main stream - var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; + playlistUrl += queryString; - playlistUrl += queryString; + var subtitleStreams = state.MediaSource + .MediaStreams + .Where(i => i.IsTextSubtitleStream) + .ToList(); - var subtitleStreams = state.MediaSource - .MediaStreams - .Where(i => i.IsTextSubtitleStream) - .ToList(); + var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest) + ? "subs" + : null; - var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest) - ? "subs" - : null; + // If we're burning in subtitles then don't add additional subs to the manifest + if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) + { + subtitleGroup = null; + } - // If we're burning in subtitles then don't add additional subs to the manifest - if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) - { - subtitleGroup = null; - } + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); + } + + var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); - if (!string.IsNullOrWhiteSpace(subtitleGroup)) + if (state.VideoStream is not null && state.VideoRequest is not null) + { + // Provide a workaround for the case issue between flac and fLaC. + var flacWaPlaylist = ApplyFlacCaseWorkaround(state, basicPlaylist.ToString()); + if (!string.IsNullOrEmpty(flacWaPlaylist)) { - AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); + builder.Append(flacWaPlaylist); } - var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - if (state.VideoStream is not null && state.VideoRequest is not null) + // Provide SDR HEVC entrance for backward compatibility. + if (encodingOptions.AllowHevcEncoding + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrEmpty(state.VideoStream.VideoRange) + && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) { - // Provide a workaround for the case issue between flac and fLaC. - var flacWaPlaylist = ApplyFlacCaseWorkaround(state, basicPlaylist.ToString()); - if (!string.IsNullOrEmpty(flacWaPlaylist)) - { - builder.Append(flacWaPlaylist); - } - - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - - // Provide SDR HEVC entrance for backward compatibility. - if (encodingOptions.AllowHevcEncoding - && EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && !string.IsNullOrEmpty(state.VideoStream.VideoRange) - && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) - && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - var requestedVideoProfiles = state.GetRequestedProfiles("hevc"); - if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0) - { - // Force HEVC Main Profile and disable video stream copy. - state.OutputVideoCodec = "hevc"; - var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main"); - sdrVideoUrl += "&AllowVideoStreamCopy=false"; - - var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - var sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0; - var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; - - var sdrPlaylist = AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); - - // Provide a workaround for the case issue between flac and fLaC. - flacWaPlaylist = ApplyFlacCaseWorkaround(state, sdrPlaylist.ToString()); - if (!string.IsNullOrEmpty(flacWaPlaylist)) - { - builder.Append(flacWaPlaylist); - } - - // Restore the video codec - state.OutputVideoCodec = "copy"; - } - } - - // Provide Level 5.0 entrance for backward compatibility. - // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video, - // but in fact it is capable of playing videos up to Level 6.1. - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && state.VideoStream.Level.HasValue - && state.VideoStream.Level > 150 - && !string.IsNullOrEmpty(state.VideoStream.VideoRange) - && string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase) - && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + var requestedVideoProfiles = state.GetRequestedProfiles("hevc"); + if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0) { - var playlistCodecsField = new StringBuilder(); - AppendPlaylistCodecsField(playlistCodecsField, state); + // Force HEVC Main Profile and disable video stream copy. + state.OutputVideoCodec = "hevc"; + var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main"); + sdrVideoUrl += "&AllowVideoStreamCopy=false"; - // Force the video level to 5.0. - var originalLevel = state.VideoStream.Level; - state.VideoStream.Level = 150; - var newPlaylistCodecsField = new StringBuilder(); - AppendPlaylistCodecsField(newPlaylistCodecsField, state); + var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); + var sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0; + var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; - // Restore the video level. - state.VideoStream.Level = originalLevel; - var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField); - builder.Append(newPlaylist); + var sdrPlaylist = AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); // Provide a workaround for the case issue between flac and fLaC. - flacWaPlaylist = ApplyFlacCaseWorkaround(state, newPlaylist); + flacWaPlaylist = ApplyFlacCaseWorkaround(state, sdrPlaylist.ToString()); if (!string.IsNullOrEmpty(flacWaPlaylist)) { builder.Append(flacWaPlaylist); } + + // Restore the video codec + state.OutputVideoCodec = "copy"; } } - if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp())) - { - var requestedVideoBitrate = state.VideoRequest is null ? 0 : state.VideoRequest.VideoBitRate ?? 0; - - // By default, vary by just 200k - var variation = GetBitrateVariation(totalBitrate); - - var newBitrate = totalBitrate - variation; - var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); - AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + // Provide Level 5.0 entrance for backward compatibility. + // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video, + // but in fact it is capable of playing videos up to Level 6.1. + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream.Level.HasValue + && state.VideoStream.Level > 150 + && !string.IsNullOrEmpty(state.VideoStream.VideoRange) + && string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase) + && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + var playlistCodecsField = new StringBuilder(); + AppendPlaylistCodecsField(playlistCodecsField, state); + + // Force the video level to 5.0. + var originalLevel = state.VideoStream.Level; + state.VideoStream.Level = 150; + var newPlaylistCodecsField = new StringBuilder(); + AppendPlaylistCodecsField(newPlaylistCodecsField, state); + + // Restore the video level. + state.VideoStream.Level = originalLevel; + var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField); + builder.Append(newPlaylist); - variation *= 2; - newBitrate = totalBitrate - variation; - variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); - AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + // Provide a workaround for the case issue between flac and fLaC. + flacWaPlaylist = ApplyFlacCaseWorkaround(state, newPlaylist); + if (!string.IsNullOrEmpty(flacWaPlaylist)) + { + builder.Append(flacWaPlaylist); + } } - - return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); } - private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp())) { - var playlistBuilder = new StringBuilder(); - playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") - .Append(bitrate.ToString(CultureInfo.InvariantCulture)) - .Append(",AVERAGE-BANDWIDTH=") - .Append(bitrate.ToString(CultureInfo.InvariantCulture)); + var requestedVideoBitrate = state.VideoRequest is null ? 0 : state.VideoRequest.VideoBitRate ?? 0; - AppendPlaylistVideoRangeField(playlistBuilder, state); + // By default, vary by just 200k + var variation = GetBitrateVariation(totalBitrate); - AppendPlaylistCodecsField(playlistBuilder, state); + var newBitrate = totalBitrate - variation; + var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); - AppendPlaylistResolutionField(playlistBuilder, state); + variation *= 2; + newBitrate = totalBitrate - variation; + variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + } - AppendPlaylistFramerateField(playlistBuilder, state); + return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); + } - if (!string.IsNullOrWhiteSpace(subtitleGroup)) - { - playlistBuilder.Append(",SUBTITLES=\"") - .Append(subtitleGroup) - .Append('"'); - } + private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + { + var playlistBuilder = new StringBuilder(); + playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)) + .Append(",AVERAGE-BANDWIDTH=") + .Append(bitrate.ToString(CultureInfo.InvariantCulture)); - playlistBuilder.Append(Environment.NewLine); - playlistBuilder.AppendLine(url); - builder.Append(playlistBuilder); + AppendPlaylistVideoRangeField(playlistBuilder, state); - return playlistBuilder; + AppendPlaylistCodecsField(playlistBuilder, state); + + AppendPlaylistResolutionField(playlistBuilder, state); + + AppendPlaylistFramerateField(playlistBuilder, state); + + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + playlistBuilder.Append(",SUBTITLES=\"") + .Append(subtitleGroup) + .Append('"'); } - /// <summary> - /// Appends a VIDEO-RANGE field containing the range of the output video stream. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state) + playlistBuilder.Append(Environment.NewLine); + playlistBuilder.AppendLine(url); + builder.Append(playlistBuilder); + + return playlistBuilder; + } + + /// <summary> + /// Appends a VIDEO-RANGE field containing the range of the output video stream. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state) + { + if (state.VideoStream is not null && !string.IsNullOrEmpty(state.VideoStream.VideoRange)) { - if (state.VideoStream is not null && !string.IsNullOrEmpty(state.VideoStream.VideoRange)) + var videoRange = state.VideoStream.VideoRange; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { - var videoRange = state.VideoStream.VideoRange; - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase)) - { - builder.Append(",VIDEO-RANGE=SDR"); - } - - if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase)) - { - builder.Append(",VIDEO-RANGE=PQ"); - } - } - else + if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase)) { - // Currently we only encode to SDR. builder.Append(",VIDEO-RANGE=SDR"); } - } - } - /// <summary> - /// Appends a CODECS field containing formatted strings of - /// the active streams output video and audio codecs. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> - /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state) - { - // Video - string videoCodecs = string.Empty; - int? videoCodecLevel = GetOutputVideoCodecLevel(state); - if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue) - { - videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value); + if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase)) + { + builder.Append(",VIDEO-RANGE=PQ"); + } } - - // Audio - string audioCodecs = string.Empty; - if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec)) + else { - audioCodecs = GetPlaylistAudioCodecs(state); + // Currently we only encode to SDR. + builder.Append(",VIDEO-RANGE=SDR"); } + } + } - StringBuilder codecs = new StringBuilder(); + /// <summary> + /// Appends a CODECS field containing formatted strings of + /// the active streams output video and audio codecs. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> + /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state) + { + // Video + string videoCodecs = string.Empty; + int? videoCodecLevel = GetOutputVideoCodecLevel(state); + if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue) + { + videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value); + } - codecs.Append(videoCodecs); + // Audio + string audioCodecs = string.Empty; + if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec)) + { + audioCodecs = GetPlaylistAudioCodecs(state); + } - if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs)) - { - codecs.Append(','); - } + StringBuilder codecs = new StringBuilder(); - codecs.Append(audioCodecs); + codecs.Append(videoCodecs); - if (codecs.Length > 1) - { - builder.Append(",CODECS=\"") - .Append(codecs) - .Append('"'); - } + if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs)) + { + codecs.Append(','); } - /// <summary> - /// Appends a RESOLUTION field containing the resolution of the output stream. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state) + codecs.Append(audioCodecs); + + if (codecs.Length > 1) { - if (state.OutputWidth.HasValue && state.OutputHeight.HasValue) - { - builder.Append(",RESOLUTION=") - .Append(state.OutputWidth.GetValueOrDefault()) - .Append('x') - .Append(state.OutputHeight.GetValueOrDefault()); - } + builder.Append(",CODECS=\"") + .Append(codecs) + .Append('"'); } + } - /// <summary> - /// Appends a FRAME-RATE field containing the framerate of the output stream. - /// </summary> - /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> - /// <param name="builder">StringBuilder to append the field to.</param> - /// <param name="state">StreamState of the current stream.</param> - private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state) + /// <summary> + /// Appends a RESOLUTION field containing the resolution of the output stream. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state) + { + if (state.OutputWidth.HasValue && state.OutputHeight.HasValue) { - double? framerate = null; - if (state.TargetFramerate.HasValue) - { - framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3); - } - else if (state.VideoStream?.RealFrameRate is not null) - { - framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3); - } + builder.Append(",RESOLUTION=") + .Append(state.OutputWidth.GetValueOrDefault()) + .Append('x') + .Append(state.OutputHeight.GetValueOrDefault()); + } + } - if (framerate.HasValue) - { - builder.Append(",FRAME-RATE=") - .Append(framerate.Value.ToString(CultureInfo.InvariantCulture)); - } + /// <summary> + /// Appends a FRAME-RATE field containing the framerate of the output stream. + /// </summary> + /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/> + /// <param name="builder">StringBuilder to append the field to.</param> + /// <param name="state">StreamState of the current stream.</param> + private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state) + { + double? framerate = null; + if (state.TargetFramerate.HasValue) + { + framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3); + } + else if (state.VideoStream?.RealFrameRate is not null) + { + framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3); } - private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, IPAddress ipAddress) + if (framerate.HasValue) { - // Within the local network this will likely do more harm than good. - if (_networkManager.IsInLocalNetwork(ipAddress)) - { - return false; - } + builder.Append(",FRAME-RATE=") + .Append(framerate.Value.ToString(CultureInfo.InvariantCulture)); + } + } - if (!enableAdaptiveBitrateStreaming) - { - return false; - } + private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming, IPAddress ipAddress) + { + // Within the local network this will likely do more harm than good. + if (_networkManager.IsInLocalNetwork(ipAddress)) + { + return false; + } - if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath)) - { - // Opening live streams is so slow it's not even worth it - return false; - } + if (!enableAdaptiveBitrateStreaming) + { + return false; + } - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - return false; - } + if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath)) + { + // Opening live streams is so slow it's not even worth it + return false; + } - if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec)) - { - return false; - } + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + return false; + } - if (!state.IsOutputVideo) - { - return false; - } + if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec)) + { + return false; + } - // Having problems in android + if (!state.IsOutputVideo) + { return false; - // return state.VideoRequest.VideoBitRate.HasValue; } - private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder, ClaimsPrincipal user) + // Having problems in android + return false; + // return state.VideoRequest.VideoBitRate.HasValue; + } + + private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder, ClaimsPrincipal user) + { + if (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Drop) { - if (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Drop) - { - return; - } + return; + } - var selectedIndex = state.SubtitleStream is null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index; - const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\""; + var selectedIndex = state.SubtitleStream is null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index; + const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\""; - foreach (var stream in subtitles) - { - var name = stream.DisplayTitle; - - var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index; - var isForced = stream.IsForced; - - var url = string.Format( - CultureInfo.InvariantCulture, - "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}", - state.Request.MediaSourceId, - stream.Index.ToString(CultureInfo.InvariantCulture), - 30.ToString(CultureInfo.InvariantCulture), - user.GetToken()); - - var line = string.Format( - CultureInfo.InvariantCulture, - Format, - name, - isDefault ? "YES" : "NO", - isForced ? "YES" : "NO", - url, - stream.Language ?? "Unknown"); - - builder.AppendLine(line); - } + foreach (var stream in subtitles) + { + var name = stream.DisplayTitle; + + var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index; + var isForced = stream.IsForced; + + var url = string.Format( + CultureInfo.InvariantCulture, + "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}", + state.Request.MediaSourceId, + stream.Index.ToString(CultureInfo.InvariantCulture), + 30.ToString(CultureInfo.InvariantCulture), + user.GetToken()); + + var line = string.Format( + CultureInfo.InvariantCulture, + Format, + name, + isDefault ? "YES" : "NO", + isForced ? "YES" : "NO", + url, + stream.Language ?? "Unknown"); + + builder.AppendLine(line); } + } - /// <summary> - /// Get the H.26X level of the output video stream. - /// </summary> - /// <param name="state">StreamState of the current stream.</param> - /// <returns>H.26X level of the output video stream.</returns> - private int? GetOutputVideoCodecLevel(StreamState state) + /// <summary> + /// Get the H.26X level of the output video stream. + /// </summary> + /// <param name="state">StreamState of the current stream.</param> + /// <returns>H.26X level of the output video stream.</returns> + private int? GetOutputVideoCodecLevel(StreamState state) + { + string levelString = string.Empty; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream is not null + && state.VideoStream.Level.HasValue) { - string levelString = string.Empty; - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && state.VideoStream is not null - && state.VideoStream.Level.HasValue) + levelString = state.VideoStream.Level.ToString() ?? string.Empty; + } + else + { + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) { - levelString = state.VideoStream.Level.ToString() ?? string.Empty; + levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); } - else - { - if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) - { - levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41"; - levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); - } - if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120"; - levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); - } - } - - if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) { - return parsedLevel; + levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120"; + levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString); } - - return null; } - /// <summary> - /// Get the H.26X profile of the output video stream. - /// </summary> - /// <param name="state">StreamState of the current stream.</param> - /// <param name="codec">Video codec.</param> - /// <returns>H.26X profile of the output video stream.</returns> - private string GetOutputVideoCodecProfile(StreamState state, string codec) + if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel)) { - string profileString = string.Empty; - if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && !string.IsNullOrEmpty(state.VideoStream.Profile)) - { - profileString = state.VideoStream.Profile; - } - else if (!string.IsNullOrEmpty(codec)) - { - profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty; - if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) - { - profileString ??= "high"; - } + return parsedLevel; + } - if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - profileString ??= "main"; - } - } + return null; + } - return profileString; + /// <summary> + /// Get the H.26X profile of the output video stream. + /// </summary> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="codec">Video codec.</param> + /// <returns>H.26X profile of the output video stream.</returns> + private string GetOutputVideoCodecProfile(StreamState state, string codec) + { + string profileString = string.Empty; + if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && !string.IsNullOrEmpty(state.VideoStream.Profile)) + { + profileString = state.VideoStream.Profile; } - - /// <summary> - /// Gets a formatted string of the output audio codec, for use in the CODECS field. - /// </summary> - /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> - /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> - /// <param name="state">StreamState of the current stream.</param> - /// <returns>Formatted audio codec string.</returns> - private string GetPlaylistAudioCodecs(StreamState state) + else if (!string.IsNullOrEmpty(codec)) { - if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) + profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty; + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) { - string? profile = state.GetRequestedProfiles("aac").FirstOrDefault(); - return HlsCodecStringHelpers.GetAACString(profile); + profileString ??= "high"; } - if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) { - return HlsCodecStringHelpers.GetMP3String(); + profileString ??= "main"; } + } - if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetAC3String(); - } + return profileString; + } - if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetEAC3String(); - } + /// <summary> + /// Gets a formatted string of the output audio codec, for use in the CODECS field. + /// </summary> + /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> + /// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/> + /// <param name="state">StreamState of the current stream.</param> + /// <returns>Formatted audio codec string.</returns> + private string GetPlaylistAudioCodecs(StreamState state) + { + if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) + { + string? profile = state.GetRequestedProfiles("aac").FirstOrDefault(); + return HlsCodecStringHelpers.GetAACString(profile); + } - if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetFLACString(); - } + if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetMP3String(); + } - if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetALACString(); - } + if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetAC3String(); + } - if (string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)) - { - return HlsCodecStringHelpers.GetOPUSString(); - } + if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetEAC3String(); + } - return string.Empty; + if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetFLACString(); } - /// <summary> - /// Gets a formatted string of the output video codec, for use in the CODECS field. - /// </summary> - /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> - /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> - /// <param name="state">StreamState of the current stream.</param> - /// <param name="codec">Video codec.</param> - /// <param name="level">Video level.</param> - /// <returns>Formatted video codec string.</returns> - private string GetPlaylistVideoCodecs(StreamState state, string codec, int level) + if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase)) { - if (level == 0) - { - // This is 0 when there's no requested H.26X level in the device profile - // and the source is not encoded in H.26X - _logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist"); - return string.Empty; - } + return HlsCodecStringHelpers.GetALACString(); + } - if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) - { - string profile = GetOutputVideoCodecProfile(state, "h264"); - return HlsCodecStringHelpers.GetH264String(profile, level); - } + if (string.Equals(state.ActualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetOPUSString(); + } - if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - string profile = GetOutputVideoCodecProfile(state, "hevc"); - return HlsCodecStringHelpers.GetH265String(profile, level); - } + return string.Empty; + } + /// <summary> + /// Gets a formatted string of the output video codec, for use in the CODECS field. + /// </summary> + /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> + /// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/> + /// <param name="state">StreamState of the current stream.</param> + /// <param name="codec">Video codec.</param> + /// <param name="level">Video level.</param> + /// <returns>Formatted video codec string.</returns> + private string GetPlaylistVideoCodecs(StreamState state, string codec, int level) + { + if (level == 0) + { + // This is 0 when there's no requested H.26X level in the device profile + // and the source is not encoded in H.26X + _logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist"); return string.Empty; } - private int GetBitrateVariation(int bitrate) + if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) { - // By default, vary by just 50k - var variation = 50000; - - if (bitrate >= 10000000) - { - variation = 2000000; - } - else if (bitrate >= 5000000) - { - variation = 1500000; - } - else if (bitrate >= 3000000) - { - variation = 1000000; - } - else if (bitrate >= 2000000) - { - variation = 500000; - } - else if (bitrate >= 1000000) - { - variation = 300000; - } - else if (bitrate >= 600000) - { - variation = 200000; - } - else if (bitrate >= 400000) - { - variation = 100000; - } - - return variation; + string profile = GetOutputVideoCodecProfile(state, "h264"); + return HlsCodecStringHelpers.GetH264String(profile, level); } - private string ReplaceVideoBitrate(string url, int oldValue, int newValue) + if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) { - return url.Replace( - "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), - "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), - StringComparison.OrdinalIgnoreCase); + string profile = GetOutputVideoCodecProfile(state, "hevc"); + return HlsCodecStringHelpers.GetH265String(profile, level); } - private string ReplaceProfile(string url, string codec, string oldValue, string newValue) + return string.Empty; + } + + private int GetBitrateVariation(int bitrate) + { + // By default, vary by just 50k + var variation = 50000; + + if (bitrate >= 10000000) { - string profileStr = codec + "-profile="; - return url.Replace( - profileStr + oldValue, - profileStr + newValue, - StringComparison.OrdinalIgnoreCase); + variation = 2000000; } - - private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) + else if (bitrate >= 5000000) { - var oldPlaylist = playlist.ToString(); - return oldPlaylist.Replace( - oldValue.ToString(), - newValue.ToString(), - StringComparison.Ordinal); + variation = 1500000; } - - private string ApplyFlacCaseWorkaround(StreamState state, string srcPlaylist) + else if (bitrate >= 3000000) { - if (!string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) - { - return string.Empty; - } + variation = 1000000; + } + else if (bitrate >= 2000000) + { + variation = 500000; + } + else if (bitrate >= 1000000) + { + variation = 300000; + } + else if (bitrate >= 600000) + { + variation = 200000; + } + else if (bitrate >= 400000) + { + variation = 100000; + } - var newPlaylist = srcPlaylist.Replace(",flac\"", ",fLaC\"", StringComparison.Ordinal); + return variation; + } + + private string ReplaceVideoBitrate(string url, int oldValue, int newValue) + { + return url.Replace( + "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture), + "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture), + StringComparison.OrdinalIgnoreCase); + } + + private string ReplaceProfile(string url, string codec, string oldValue, string newValue) + { + string profileStr = codec + "-profile="; + return url.Replace( + profileStr + oldValue, + profileStr + newValue, + StringComparison.OrdinalIgnoreCase); + } - return newPlaylist.Contains(",fLaC\"", StringComparison.Ordinal) ? newPlaylist : string.Empty; + private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) + { + var oldPlaylist = playlist.ToString(); + return oldPlaylist.Replace( + oldValue.ToString(), + newValue.ToString(), + StringComparison.Ordinal); + } + + private string ApplyFlacCaseWorkaround(StreamState state, string srcPlaylist) + { + if (!string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; } + + var newPlaylist = srcPlaylist.Replace(",flac\"", ",fLaC\"", StringComparison.Ordinal); + + return newPlaylist.Contains(",fLaC\"", StringComparison.Ordinal) ? newPlaylist : string.Empty; } } diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 5bdd3fe2e..0f0a70c69 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -11,110 +11,109 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// The stream response helpers. +/// </summary> +public static class FileStreamResponseHelpers { /// <summary> - /// The stream response helpers. + /// Returns a static file from a remote source. /// </summary> - public static class FileStreamResponseHelpers + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param> + /// <param name="httpContext">The current http context.</param> + /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> + /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns> + public static async Task<ActionResult> GetStaticRemoteStreamResult( + StreamState state, + HttpClient httpClient, + HttpContext httpContext, + CancellationToken cancellationToken = default) { - /// <summary> - /// Returns a static file from a remote source. - /// </summary> - /// <param name="state">The current <see cref="StreamState"/>.</param> - /// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param> - /// <param name="httpContext">The current http context.</param> - /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param> - /// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns> - public static async Task<ActionResult> GetStaticRemoteStreamResult( - StreamState state, - HttpClient httpClient, - HttpContext httpContext, - CancellationToken cancellationToken = default) + if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) { - if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) - { - httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent); - } + httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent); + } - // Can't dispose the response as it's required up the call chain. - var response = await httpClient.GetAsync(new Uri(state.MediaPath), cancellationToken).ConfigureAwait(false); - var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain; + // Can't dispose the response as it's required up the call chain. + var response = await httpClient.GetAsync(new Uri(state.MediaPath), cancellationToken).ConfigureAwait(false); + var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain; - httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; + httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; - return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType); - } + return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType); + } - /// <summary> - /// Returns a static file from the server. - /// </summary> - /// <param name="path">The path to the file.</param> - /// <param name="contentType">The content type of the file.</param> - /// <returns>An <see cref="ActionResult"/> the file.</returns> - public static ActionResult GetStaticFileResult( - string path, - string contentType) - { - return new PhysicalFileResult(path, contentType) { EnableRangeProcessing = true }; - } + /// <summary> + /// Returns a static file from the server. + /// </summary> + /// <param name="path">The path to the file.</param> + /// <param name="contentType">The content type of the file.</param> + /// <returns>An <see cref="ActionResult"/> the file.</returns> + public static ActionResult GetStaticFileResult( + string path, + string contentType) + { + return new PhysicalFileResult(path, contentType) { EnableRangeProcessing = true }; + } - /// <summary> - /// Returns a transcoded file from the server. - /// </summary> - /// <param name="state">The current <see cref="StreamState"/>.</param> - /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param> - /// <param name="httpContext">The current http context.</param> - /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param> - /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param> - /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> - /// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param> - /// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns> - public static async Task<ActionResult> GetTranscodedFile( - StreamState state, - bool isHeadRequest, - HttpContext httpContext, - TranscodingJobHelper transcodingJobHelper, - string ffmpegCommandLineArguments, - TranscodingJobType transcodingJobType, - CancellationTokenSource cancellationTokenSource) - { - // Use the command line args with a dummy playlist path - var outputPath = state.OutputFilePath; + /// <summary> + /// Returns a transcoded file from the server. + /// </summary> + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param> + /// <param name="httpContext">The current http context.</param> + /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param> + /// <param name="ffmpegCommandLineArguments">The command line arguments to start ffmpeg.</param> + /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> + /// <param name="cancellationTokenSource">The <see cref="CancellationTokenSource"/>.</param> + /// <returns>A <see cref="Task{ActionResult}"/> containing the transcoded file.</returns> + public static async Task<ActionResult> GetTranscodedFile( + StreamState state, + bool isHeadRequest, + HttpContext httpContext, + TranscodingJobHelper transcodingJobHelper, + string ffmpegCommandLineArguments, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource) + { + // Use the command line args with a dummy playlist path + var outputPath = state.OutputFilePath; - httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; + httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none"; - var contentType = state.GetMimeType(outputPath); + var contentType = state.GetMimeType(outputPath); - // Headers only - if (isHeadRequest) - { - httpContext.Response.Headers[HeaderNames.ContentType] = contentType; - return new OkResult(); - } + // Headers only + if (isHeadRequest) + { + httpContext.Response.Headers[HeaderNames.ContentType] = contentType; + return new OkResult(); + } - var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath); - await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); - try + var transcodingLock = transcodingJobHelper.GetTranscodingLock(outputPath); + await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + try + { + TranscodingJobDto? job; + if (!File.Exists(outputPath)) { - TranscodingJobDto? job; - if (!File.Exists(outputPath)) - { - job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); - } - else - { - job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive); - state.Dispose(); - } - - var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper); - return new FileStreamResult(stream, contentType); + job = await transcodingJobHelper.StartFfMpeg(state, outputPath, ffmpegCommandLineArguments, httpContext.Request, transcodingJobType, cancellationTokenSource).ConfigureAwait(false); } - finally + else { - transcodingLock.Release(); + job = transcodingJobHelper.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive); + state.Dispose(); } + + var stream = new ProgressiveFileStream(outputPath, job, transcodingJobHelper); + return new FileStreamResult(stream, contentType); + } + finally + { + transcodingLock.Release(); } } } diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index cbe82979b..995488397 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -2,182 +2,181 @@ using System.Globalization; using System.Text; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Hls Codec string helpers. +/// </summary> +public static class HlsCodecStringHelpers { /// <summary> - /// Hls Codec string helpers. + /// Codec name for MP3. + /// </summary> + public const string MP3 = "mp4a.40.34"; + + /// <summary> + /// Codec name for AC-3. + /// </summary> + public const string AC3 = "mp4a.a5"; + + /// <summary> + /// Codec name for E-AC-3. + /// </summary> + public const string EAC3 = "mp4a.a6"; + + /// <summary> + /// Codec name for FLAC. + /// </summary> + public const string FLAC = "flac"; + + /// <summary> + /// Codec name for ALAC. + /// </summary> + public const string ALAC = "alac"; + + /// <summary> + /// Codec name for OPUS. + /// </summary> + public const string OPUS = "opus"; + + /// <summary> + /// Gets a MP3 codec string. /// </summary> - public static class HlsCodecStringHelpers + /// <returns>MP3 codec string.</returns> + public static string GetMP3String() { - /// <summary> - /// Codec name for MP3. - /// </summary> - public const string MP3 = "mp4a.40.34"; - - /// <summary> - /// Codec name for AC-3. - /// </summary> - public const string AC3 = "mp4a.a5"; - - /// <summary> - /// Codec name for E-AC-3. - /// </summary> - public const string EAC3 = "mp4a.a6"; - - /// <summary> - /// Codec name for FLAC. - /// </summary> - public const string FLAC = "flac"; - - /// <summary> - /// Codec name for ALAC. - /// </summary> - public const string ALAC = "alac"; - - /// <summary> - /// Codec name for OPUS. - /// </summary> - public const string OPUS = "opus"; - - /// <summary> - /// Gets a MP3 codec string. - /// </summary> - /// <returns>MP3 codec string.</returns> - public static string GetMP3String() - { - return MP3; - } + return MP3; + } - /// <summary> - /// Gets an AAC codec string. - /// </summary> - /// <param name="profile">AAC profile.</param> - /// <returns>AAC codec string.</returns> - public static string GetAACString(string? profile) + /// <summary> + /// Gets an AAC codec string. + /// </summary> + /// <param name="profile">AAC profile.</param> + /// <returns>AAC codec string.</returns> + public static string GetAACString(string? profile) + { + StringBuilder result = new StringBuilder("mp4a", 9); + + if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase)) { - StringBuilder result = new StringBuilder("mp4a", 9); - - if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase)) - { - result.Append(".40.5"); - } - else - { - // Default to LC if profile is invalid - result.Append(".40.2"); - } - - return result.ToString(); + result.Append(".40.5"); } - - /// <summary> - /// Gets an AC-3 codec string. - /// </summary> - /// <returns>AC-3 codec string.</returns> - public static string GetAC3String() + else { - return AC3; + // Default to LC if profile is invalid + result.Append(".40.2"); } - /// <summary> - /// Gets an E-AC-3 codec string. - /// </summary> - /// <returns>E-AC-3 codec string.</returns> - public static string GetEAC3String() + return result.ToString(); + } + + /// <summary> + /// Gets an AC-3 codec string. + /// </summary> + /// <returns>AC-3 codec string.</returns> + public static string GetAC3String() + { + return AC3; + } + + /// <summary> + /// Gets an E-AC-3 codec string. + /// </summary> + /// <returns>E-AC-3 codec string.</returns> + public static string GetEAC3String() + { + return EAC3; + } + + /// <summary> + /// Gets an FLAC codec string. + /// </summary> + /// <returns>FLAC codec string.</returns> + public static string GetFLACString() + { + return FLAC; + } + + /// <summary> + /// Gets an ALAC codec string. + /// </summary> + /// <returns>ALAC codec string.</returns> + public static string GetALACString() + { + return ALAC; + } + + /// <summary> + /// Gets an OPUS codec string. + /// </summary> + /// <returns>OPUS codec string.</returns> + public static string GetOPUSString() + { + return OPUS; + } + + /// <summary> + /// Gets a H.264 codec string. + /// </summary> + /// <param name="profile">H.264 profile.</param> + /// <param name="level">H.264 level.</param> + /// <returns>H.264 string.</returns> + public static string GetH264String(string? profile, int level) + { + StringBuilder result = new StringBuilder("avc1", 11); + + if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase)) { - return EAC3; + result.Append(".6400"); } - - /// <summary> - /// Gets an FLAC codec string. - /// </summary> - /// <returns>FLAC codec string.</returns> - public static string GetFLACString() + else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase)) { - return FLAC; + result.Append(".4D40"); } - - /// <summary> - /// Gets an ALAC codec string. - /// </summary> - /// <returns>ALAC codec string.</returns> - public static string GetALACString() + else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase)) { - return ALAC; + result.Append(".42E0"); } - - /// <summary> - /// Gets an OPUS codec string. - /// </summary> - /// <returns>OPUS codec string.</returns> - public static string GetOPUSString() + else { - return OPUS; + // Default to constrained baseline if profile is invalid + result.Append(".4240"); } - /// <summary> - /// Gets a H.264 codec string. - /// </summary> - /// <param name="profile">H.264 profile.</param> - /// <param name="level">H.264 level.</param> - /// <returns>H.264 string.</returns> - public static string GetH264String(string? profile, int level) + string levelHex = level.ToString("X2", CultureInfo.InvariantCulture); + result.Append(levelHex); + + return result.ToString(); + } + + /// <summary> + /// Gets a H.265 codec string. + /// </summary> + /// <param name="profile">H.265 profile.</param> + /// <param name="level">H.265 level.</param> + /// <returns>H.265 string.</returns> + public static string GetH265String(string? profile, int level) + { + // The h265 syntax is a bit of a mystery at the time this comment was written. + // This is what I've found through various sources: + // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN] + StringBuilder result = new StringBuilder("hvc1", 16); + + if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase)) { - StringBuilder result = new StringBuilder("avc1", 11); - - if (string.Equals(profile, "high", StringComparison.OrdinalIgnoreCase)) - { - result.Append(".6400"); - } - else if (string.Equals(profile, "main", StringComparison.OrdinalIgnoreCase)) - { - result.Append(".4D40"); - } - else if (string.Equals(profile, "baseline", StringComparison.OrdinalIgnoreCase)) - { - result.Append(".42E0"); - } - else - { - // Default to constrained baseline if profile is invalid - result.Append(".4240"); - } - - string levelHex = level.ToString("X2", CultureInfo.InvariantCulture); - result.Append(levelHex); - - return result.ToString(); + result.Append(".2.4"); } - - /// <summary> - /// Gets a H.265 codec string. - /// </summary> - /// <param name="profile">H.265 profile.</param> - /// <param name="level">H.265 level.</param> - /// <returns>H.265 string.</returns> - public static string GetH265String(string? profile, int level) + else { - // The h265 syntax is a bit of a mystery at the time this comment was written. - // This is what I've found through various sources: - // FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN] - StringBuilder result = new StringBuilder("hvc1", 16); - - if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase) - || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase)) - { - result.Append(".2.4"); - } - else - { - // Default to main if profile is invalid - result.Append(".1.4"); - } - - result.Append(".L") - .Append(level) - .Append(".B0"); - - return result.ToString(); + // Default to main if profile is invalid + result.Append(".1.4"); } + + result.Append(".L") + .Append(level) + .Append(".B0"); + + return result.ToString(); } } diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index 671107c1f..2155e305d 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -8,131 +8,130 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// The hls helpers. +/// </summary> +public static class HlsHelpers { /// <summary> - /// The hls helpers. + /// Waits for a minimum number of segments to be available. /// </summary> - public static class HlsHelpers + /// <param name="playlist">The playlist string.</param> + /// <param name="segmentCount">The segment count.</param> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> + /// <returns>A <see cref="Task"/> indicating the waiting process.</returns> + public static async Task WaitForMinimumSegmentCount(string playlist, int? segmentCount, ILogger logger, CancellationToken cancellationToken) { - /// <summary> - /// Waits for a minimum number of segments to be available. - /// </summary> - /// <param name="playlist">The playlist string.</param> - /// <param name="segmentCount">The segment count.</param> - /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> - /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> - /// <returns>A <see cref="Task"/> indicating the waiting process.</returns> - public static async Task WaitForMinimumSegmentCount(string playlist, int? segmentCount, ILogger logger, CancellationToken cancellationToken) - { - logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist); + logger.LogDebug("Waiting for {0} segments in {1}", segmentCount, playlist); - while (!cancellationToken.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) + { + try { - try + // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written + var fileStream = new FileStream( + playlist, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using (fileStream.ConfigureAwait(false)) { - // Need to use FileShare.ReadWrite because we're reading the file at the same time it's being written - var fileStream = new FileStream( - playlist, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite, - IODefaults.FileStreamBufferSize, - FileOptions.Asynchronous | FileOptions.SequentialScan); - await using (fileStream.ConfigureAwait(false)) - { - using var reader = new StreamReader(fileStream); - var count = 0; + using var reader = new StreamReader(fileStream); + var count = 0; - while (!reader.EndOfStream) + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) { - var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); - if (line is null) - { - // Nothing currently in buffer. - break; - } + // Nothing currently in buffer. + break; + } - if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1) + if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1) + { + count++; + if (count >= segmentCount) { - count++; - if (count >= segmentCount) - { - logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist); - return; - } + logger.LogDebug("Finished waiting for {0} segments in {1}", segmentCount, playlist); + return; } } } - - await Task.Delay(100, cancellationToken).ConfigureAwait(false); - } - catch (IOException) - { - // May get an error if the file is locked } - await Task.Delay(50, cancellationToken).ConfigureAwait(false); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); } - } - - /// <summary> - /// Gets the #EXT-X-MAP string. - /// </summary> - /// <param name="outputPath">The output path of the file.</param> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <param name="isOsDepends">Get a normal string or depends on OS.</param> - /// <returns>The string text of #EXT-X-MAP.</returns> - public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends) - { - var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); - var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); - var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); - - // on Linux/Unix - // #EXT-X-MAP:URI="prefix-1.mp4" - var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension; - if (!isOsDepends) + catch (IOException) { - return fmp4InitFileName; + // May get an error if the file is locked } - if (OperatingSystem.IsWindows()) - { - // on Windows - // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4" - fmp4InitFileName = outputPrefix + "-1" + outputExtension; - } + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + /// <summary> + /// Gets the #EXT-X-MAP string. + /// </summary> + /// <param name="outputPath">The output path of the file.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <param name="isOsDepends">Get a normal string or depends on OS.</param> + /// <returns>The string text of #EXT-X-MAP.</returns> + public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends) + { + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); + var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); + var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + + // on Linux/Unix + // #EXT-X-MAP:URI="prefix-1.mp4" + var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension; + if (!isOsDepends) + { return fmp4InitFileName; } - /// <summary> - /// Gets the hls playlist text. - /// </summary> - /// <param name="path">The path to the playlist file.</param> - /// <param name="state">The <see cref="StreamState"/>.</param> - /// <returns>The playlist text as a string.</returns> - public static string GetLivePlaylistText(string path, StreamState state) + if (OperatingSystem.IsWindows()) { - var text = File.ReadAllText(path); + // on Windows + // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4" + fmp4InitFileName = outputPrefix + "-1" + outputExtension; + } - var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); - if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) - { - var fmp4InitFileName = GetFmp4InitFileName(path, state, true); - var baseUrlParam = string.Format( - CultureInfo.InvariantCulture, - "hls/{0}/", - Path.GetFileNameWithoutExtension(path)); - var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false); + return fmp4InitFileName; + } - // Replace fMP4 init file URI. - text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture); - } + /// <summary> + /// Gets the hls playlist text. + /// </summary> + /// <param name="path">The path to the playlist file.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> + /// <returns>The playlist text as a string.</returns> + public static string GetLivePlaylistText(string path, StreamState state) + { + var text = File.ReadAllText(path); + + var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); + if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var fmp4InitFileName = GetFmp4InitFileName(path, state, true); + var baseUrlParam = string.Format( + CultureInfo.InvariantCulture, + "hls/{0}/", + Path.GetFileNameWithoutExtension(path)); + var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false); - return text; + // Replace fMP4 init file URI. + text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture); } + + return text; } } diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index e0245fe4d..df37d96c6 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -25,476 +25,475 @@ using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Media info helper. +/// </summary> +public class MediaInfoHelper { + private readonly IUserManager _userManager; + private readonly ILibraryManager _libraryManager; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ILogger<MediaInfoHelper> _logger; + private readonly INetworkManager _networkManager; + private readonly IDeviceManager _deviceManager; + /// <summary> - /// Media info helper. + /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class. /// </summary> - public class MediaInfoHelper + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + public MediaInfoHelper( + IUserManager userManager, + ILibraryManager libraryManager, + IMediaSourceManager mediaSourceManager, + IMediaEncoder mediaEncoder, + IServerConfigurationManager serverConfigurationManager, + ILogger<MediaInfoHelper> logger, + INetworkManager networkManager, + IDeviceManager deviceManager) { - private readonly IUserManager _userManager; - private readonly ILibraryManager _libraryManager; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly ILogger<MediaInfoHelper> _logger; - private readonly INetworkManager _networkManager; - private readonly IDeviceManager _deviceManager; - - /// <summary> - /// Initializes a new instance of the <see cref="MediaInfoHelper"/> class. - /// </summary> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param> - /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - public MediaInfoHelper( - IUserManager userManager, - ILibraryManager libraryManager, - IMediaSourceManager mediaSourceManager, - IMediaEncoder mediaEncoder, - IServerConfigurationManager serverConfigurationManager, - ILogger<MediaInfoHelper> logger, - INetworkManager networkManager, - IDeviceManager deviceManager) - { - _userManager = userManager; - _libraryManager = libraryManager; - _mediaSourceManager = mediaSourceManager; - _mediaEncoder = mediaEncoder; - _serverConfigurationManager = serverConfigurationManager; - _logger = logger; - _networkManager = networkManager; - _deviceManager = deviceManager; - } + _userManager = userManager; + _libraryManager = libraryManager; + _mediaSourceManager = mediaSourceManager; + _mediaEncoder = mediaEncoder; + _serverConfigurationManager = serverConfigurationManager; + _logger = logger; + _networkManager = networkManager; + _deviceManager = deviceManager; + } - /// <summary> - /// Get playback info. - /// </summary> - /// <param name="id">Item id.</param> - /// <param name="userId">User Id.</param> - /// <param name="mediaSourceId">Media source id.</param> - /// <param name="liveStreamId">Live stream id.</param> - /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns> - public async Task<PlaybackInfoResponse> GetPlaybackInfo( - Guid id, - Guid? userId, - string? mediaSourceId = null, - string? liveStreamId = null) + /// <summary> + /// Get playback info. + /// </summary> + /// <param name="id">Item id.</param> + /// <param name="userId">User Id.</param> + /// <param name="mediaSourceId">Media source id.</param> + /// <param name="liveStreamId">Live stream id.</param> + /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns> + public async Task<PlaybackInfoResponse> GetPlaybackInfo( + Guid id, + Guid? userId, + string? mediaSourceId = null, + string? liveStreamId = null) + { + var user = userId is null || userId.Value.Equals(default) + ? null + : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById(id); + var result = new PlaybackInfoResponse(); + + MediaSourceInfo[] mediaSources; + if (string.IsNullOrWhiteSpace(liveStreamId)) { - var user = userId is null || userId.Value.Equals(default) - ? null - : _userManager.GetUserById(userId.Value); - var item = _libraryManager.GetItemById(id); - var result = new PlaybackInfoResponse(); - - MediaSourceInfo[] mediaSources; - if (string.IsNullOrWhiteSpace(liveStreamId)) - { - // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? - var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); - - if (string.IsNullOrWhiteSpace(mediaSourceId)) - { - mediaSources = mediaSourcesList.ToArray(); - } - else - { - mediaSources = mediaSourcesList - .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - } - } - else - { - var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); + // TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes? + var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false); - mediaSources = new[] { mediaSource }; - } - - if (mediaSources.Length == 0) + if (string.IsNullOrWhiteSpace(mediaSourceId)) { - result.MediaSources = Array.Empty<MediaSourceInfo>(); - - result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream; + mediaSources = mediaSourcesList.ToArray(); } else { - // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it - // Should we move this directly into MediaSourceManager? - var mediaSourcesClone = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources)); - if (mediaSourcesClone is not null) - { - result.MediaSources = mediaSourcesClone; - } - - result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + mediaSources = mediaSourcesList + .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); } + } + else + { + var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); - return result; + mediaSources = new[] { mediaSource }; } - /// <summary> - /// SetDeviceSpecificData. - /// </summary> - /// <param name="item">Item to set data for.</param> - /// <param name="mediaSource">Media source info.</param> - /// <param name="profile">Device profile.</param> - /// <param name="claimsPrincipal">Current claims principal.</param> - /// <param name="maxBitrate">Max bitrate.</param> - /// <param name="startTimeTicks">Start time ticks.</param> - /// <param name="mediaSourceId">Media source id.</param> - /// <param name="audioStreamIndex">Audio stream index.</param> - /// <param name="subtitleStreamIndex">Subtitle stream index.</param> - /// <param name="maxAudioChannels">Max audio channels.</param> - /// <param name="playSessionId">Play session id.</param> - /// <param name="userId">User id.</param> - /// <param name="enableDirectPlay">Enable direct play.</param> - /// <param name="enableDirectStream">Enable direct stream.</param> - /// <param name="enableTranscoding">Enable transcoding.</param> - /// <param name="allowVideoStreamCopy">Allow video stream copy.</param> - /// <param name="allowAudioStreamCopy">Allow audio stream copy.</param> - /// <param name="ipAddress">Requesting IP address.</param> - public void SetDeviceSpecificData( - BaseItem item, - MediaSourceInfo mediaSource, - DeviceProfile profile, - ClaimsPrincipal claimsPrincipal, - int? maxBitrate, - long startTimeTicks, - string mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - int? maxAudioChannels, - string playSessionId, - Guid userId, - bool enableDirectPlay, - bool enableDirectStream, - bool enableTranscoding, - bool allowVideoStreamCopy, - bool allowAudioStreamCopy, - IPAddress ipAddress) + if (mediaSources.Length == 0) { - var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); + result.MediaSources = Array.Empty<MediaSourceInfo>(); - var options = new MediaOptions - { - MediaSources = new[] { mediaSource }, - Context = EncodingContext.Streaming, - DeviceId = claimsPrincipal.GetDeviceId(), - ItemId = item.Id, - Profile = profile, - MaxAudioChannels = maxAudioChannels, - AllowAudioStreamCopy = allowAudioStreamCopy, - AllowVideoStreamCopy = allowVideoStreamCopy - }; - - if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase)) + result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream; + } + else + { + // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it + // Should we move this directly into MediaSourceManager? + var mediaSourcesClone = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources)); + if (mediaSourcesClone is not null) { - options.MediaSourceId = mediaSourceId; - options.AudioStreamIndex = audioStreamIndex; - options.SubtitleStreamIndex = subtitleStreamIndex; + result.MediaSources = mediaSourcesClone; } - var user = _userManager.GetUserById(userId); + result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + } - if (!enableDirectPlay) - { - mediaSource.SupportsDirectPlay = false; - } + return result; + } - if (!enableDirectStream || !allowVideoStreamCopy) - { - mediaSource.SupportsDirectStream = false; - } + /// <summary> + /// SetDeviceSpecificData. + /// </summary> + /// <param name="item">Item to set data for.</param> + /// <param name="mediaSource">Media source info.</param> + /// <param name="profile">Device profile.</param> + /// <param name="claimsPrincipal">Current claims principal.</param> + /// <param name="maxBitrate">Max bitrate.</param> + /// <param name="startTimeTicks">Start time ticks.</param> + /// <param name="mediaSourceId">Media source id.</param> + /// <param name="audioStreamIndex">Audio stream index.</param> + /// <param name="subtitleStreamIndex">Subtitle stream index.</param> + /// <param name="maxAudioChannels">Max audio channels.</param> + /// <param name="playSessionId">Play session id.</param> + /// <param name="userId">User id.</param> + /// <param name="enableDirectPlay">Enable direct play.</param> + /// <param name="enableDirectStream">Enable direct stream.</param> + /// <param name="enableTranscoding">Enable transcoding.</param> + /// <param name="allowVideoStreamCopy">Allow video stream copy.</param> + /// <param name="allowAudioStreamCopy">Allow audio stream copy.</param> + /// <param name="ipAddress">Requesting IP address.</param> + public void SetDeviceSpecificData( + BaseItem item, + MediaSourceInfo mediaSource, + DeviceProfile profile, + ClaimsPrincipal claimsPrincipal, + int? maxBitrate, + long startTimeTicks, + string mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + int? maxAudioChannels, + string playSessionId, + Guid userId, + bool enableDirectPlay, + bool enableDirectStream, + bool enableTranscoding, + bool allowVideoStreamCopy, + bool allowAudioStreamCopy, + IPAddress ipAddress) + { + var streamBuilder = new StreamBuilder(_mediaEncoder, _logger); - if (!enableTranscoding) - { - mediaSource.SupportsTranscoding = false; - } + var options = new MediaOptions + { + MediaSources = new[] { mediaSource }, + Context = EncodingContext.Streaming, + DeviceId = claimsPrincipal.GetDeviceId(), + ItemId = item.Id, + Profile = profile, + MaxAudioChannels = maxAudioChannels, + AllowAudioStreamCopy = allowAudioStreamCopy, + AllowVideoStreamCopy = allowVideoStreamCopy + }; + + if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase)) + { + options.MediaSourceId = mediaSourceId; + options.AudioStreamIndex = audioStreamIndex; + options.SubtitleStreamIndex = subtitleStreamIndex; + } - if (item is Audio) - { - _logger.LogInformation( - "User policy for {0}. EnableAudioPlaybackTranscoding: {1}", - user.Username, - user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); - } - else - { - _logger.LogInformation( - "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}", - user.Username, - user.HasPermission(PermissionKind.EnablePlaybackRemuxing), - user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), - user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); - } + var user = _userManager.GetUserById(userId); - options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress); + if (!enableDirectPlay) + { + mediaSource.SupportsDirectPlay = false; + } - if (!options.ForceDirectStream) - { - // direct-stream http streaming is currently broken - options.EnableDirectStream = false; - } + if (!enableDirectStream || !allowVideoStreamCopy) + { + mediaSource.SupportsDirectStream = false; + } - // Beginning of Playback Determination - var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) - ? streamBuilder.GetOptimalAudioStream(options) - : streamBuilder.GetOptimalVideoStream(options); + if (!enableTranscoding) + { + mediaSource.SupportsTranscoding = false; + } - if (streamInfo is not null) - { - streamInfo.PlaySessionId = playSessionId; - streamInfo.StartPositionTicks = startTimeTicks; + if (item is Audio) + { + _logger.LogInformation( + "User policy for {0}. EnableAudioPlaybackTranscoding: {1}", + user.Username, + user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); + } + else + { + _logger.LogInformation( + "User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}", + user.Username, + user.HasPermission(PermissionKind.EnablePlaybackRemuxing), + user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding), + user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)); + } - mediaSource.SupportsDirectPlay = streamInfo.PlayMethod == PlayMethod.DirectPlay; + options.MaxBitrate = GetMaxBitrate(maxBitrate, user, ipAddress); - // Players do not handle this being set according to PlayMethod - mediaSource.SupportsDirectStream = - options.EnableDirectStream - ? streamInfo.PlayMethod == PlayMethod.DirectPlay || streamInfo.PlayMethod == PlayMethod.DirectStream - : streamInfo.PlayMethod == PlayMethod.DirectPlay; + if (!options.ForceDirectStream) + { + // direct-stream http streaming is currently broken + options.EnableDirectStream = false; + } - mediaSource.SupportsTranscoding = - streamInfo.PlayMethod == PlayMethod.DirectStream - || mediaSource.TranscodingContainer is not null - || profile.TranscodingProfiles.Any(i => i.Type == streamInfo.MediaType && i.Context == options.Context); + // Beginning of Playback Determination + var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) + ? streamBuilder.GetOptimalAudioStream(options) + : streamBuilder.GetOptimalVideoStream(options); - if (item is Audio) - { - if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)) - { - mediaSource.SupportsTranscoding = false; - } - } - else if (item is Video) - { - if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) - && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) - && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) - { - mediaSource.SupportsTranscoding = false; - } - } + if (streamInfo is not null) + { + streamInfo.PlaySessionId = playSessionId; + streamInfo.StartPositionTicks = startTimeTicks; - if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) - { - mediaSource.SupportsDirectPlay = false; - mediaSource.SupportsDirectStream = false; + mediaSource.SupportsDirectPlay = streamInfo.PlayMethod == PlayMethod.DirectPlay; - mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); - mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; - mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; - mediaSource.TranscodingContainer = streamInfo.Container; - mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; - } - else + // Players do not handle this being set according to PlayMethod + mediaSource.SupportsDirectStream = + options.EnableDirectStream + ? streamInfo.PlayMethod == PlayMethod.DirectPlay || streamInfo.PlayMethod == PlayMethod.DirectStream + : streamInfo.PlayMethod == PlayMethod.DirectPlay; + + mediaSource.SupportsTranscoding = + streamInfo.PlayMethod == PlayMethod.DirectStream + || mediaSource.TranscodingContainer is not null + || profile.TranscodingProfiles.Any(i => i.Type == streamInfo.MediaType && i.Context == options.Context); + + if (item is Audio) + { + if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)) { - if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStream)) - { - streamInfo.PlayMethod = PlayMethod.Transcode; - mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); - - if (!allowVideoStreamCopy) - { - mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; - } - - if (!allowAudioStreamCopy) - { - mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; - } - } + mediaSource.SupportsTranscoding = false; } - - // Do this after the above so that StartPositionTicks is set - // The token must not be null - SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, claimsPrincipal.GetToken()!); - mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex; } - - foreach (var attachment in mediaSource.MediaAttachments) + else if (item is Video) { - attachment.DeliveryUrl = string.Format( - CultureInfo.InvariantCulture, - "/Videos/{0}/{1}/Attachments/{2}", - item.Id, - mediaSource.Id, - attachment.Index); + if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding) + && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding) + && !user.HasPermission(PermissionKind.EnablePlaybackRemuxing)) + { + mediaSource.SupportsTranscoding = false; + } } - } - /// <summary> - /// Sort media source. - /// </summary> - /// <param name="result">Playback info response.</param> - /// <param name="maxBitrate">Max bitrate.</param> - public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) - { - var originalList = result.MediaSources.ToList(); + if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding)) + { + mediaSource.SupportsDirectPlay = false; + mediaSource.SupportsDirectStream = false; - result.MediaSources = result.MediaSources.OrderBy(i => + mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); + mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; + mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; + mediaSource.TranscodingContainer = streamInfo.Container; + mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; + } + else + { + if (!mediaSource.SupportsDirectPlay && (mediaSource.SupportsTranscoding || mediaSource.SupportsDirectStream)) { - // Nothing beats direct playing a file - if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) - { - return 0; - } + streamInfo.PlayMethod = PlayMethod.Transcode; + mediaSource.TranscodingUrl = streamInfo.ToUrl("-", claimsPrincipal.GetToken()).TrimStart('-'); - return 1; - }) - .ThenBy(i => - { - // Let's assume direct streaming a file is just as desirable as direct playing a remote url - if (i.SupportsDirectPlay || i.SupportsDirectStream) + if (!allowVideoStreamCopy) { - return 0; + mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; } - return 1; - }) - .ThenBy(i => - { - return i.Protocol switch + if (!allowAudioStreamCopy) { - MediaProtocol.File => 0, - _ => 1, - }; - }) - .ThenBy(i => - { - if (maxBitrate.HasValue && i.Bitrate.HasValue) - { - return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2; + mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; } + } + } - return 1; - }) - .ThenBy(originalList.IndexOf) - .ToArray(); + // Do this after the above so that StartPositionTicks is set + // The token must not be null + SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, claimsPrincipal.GetToken()!); + mediaSource.DefaultAudioStreamIndex = streamInfo.AudioStreamIndex; } - /// <summary> - /// Open media source. - /// </summary> - /// <param name="httpContext">Http Context.</param> - /// <param name="request">Live stream request.</param> - /// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns> - public async Task<LiveStreamResponse> OpenMediaSource(HttpContext httpContext, LiveStreamRequest request) + foreach (var attachment in mediaSource.MediaAttachments) { - var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); + attachment.DeliveryUrl = string.Format( + CultureInfo.InvariantCulture, + "/Videos/{0}/{1}/Attachments/{2}", + item.Id, + mediaSource.Id, + attachment.Index); + } + } - var profile = request.DeviceProfile; - if (profile is null) + /// <summary> + /// Sort media source. + /// </summary> + /// <param name="result">Playback info response.</param> + /// <param name="maxBitrate">Max bitrate.</param> + public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) + { + var originalList = result.MediaSources.ToList(); + + result.MediaSources = result.MediaSources.OrderBy(i => { - var clientCapabilities = _deviceManager.GetCapabilities(httpContext.User.GetDeviceId()); - if (clientCapabilities is not null) + // Nothing beats direct playing a file + if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) { - profile = clientCapabilities.DeviceProfile; + return 0; } - } - if (profile is not null) + return 1; + }) + .ThenBy(i => { - var item = _libraryManager.GetItemById(request.ItemId); - - SetDeviceSpecificData( - item, - result.MediaSource, - profile, - httpContext.User, - request.MaxStreamingBitrate, - request.StartTimeTicks ?? 0, - result.MediaSource.Id, - request.AudioStreamIndex, - request.SubtitleStreamIndex, - request.MaxAudioChannels, - request.PlaySessionId, - request.UserId, - request.EnableDirectPlay, - request.EnableDirectStream, - true, - true, - true, - httpContext.GetNormalizedRemoteIp()); - } - else + // Let's assume direct streaming a file is just as desirable as direct playing a remote url + if (i.SupportsDirectPlay || i.SupportsDirectStream) + { + return 0; + } + + return 1; + }) + .ThenBy(i => + { + return i.Protocol switch + { + MediaProtocol.File => 0, + _ => 1, + }; + }) + .ThenBy(i => { - if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) + if (maxBitrate.HasValue && i.Bitrate.HasValue) { - result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; + return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2; } - } - // here was a check if (result.MediaSource is not null) but Rider said it will never be null - NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video); + return 1; + }) + .ThenBy(originalList.IndexOf) + .ToArray(); + } - return result; - } + /// <summary> + /// Open media source. + /// </summary> + /// <param name="httpContext">Http Context.</param> + /// <param name="request">Live stream request.</param> + /// <returns>A <see cref="Task"/> containing the <see cref="LiveStreamResponse"/>.</returns> + public async Task<LiveStreamResponse> OpenMediaSource(HttpContext httpContext, LiveStreamRequest request) + { + var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); - /// <summary> - /// Normalize media source container. - /// </summary> - /// <param name="mediaSource">Media source.</param> - /// <param name="profile">Device profile.</param> - /// <param name="type">Dlna profile type.</param> - public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) + var profile = request.DeviceProfile; + if (profile is null) { - mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profile, type); + var clientCapabilities = _deviceManager.GetCapabilities(httpContext.User.GetDeviceId()); + if (clientCapabilities is not null) + { + profile = clientCapabilities.DeviceProfile; + } } - private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) + if (profile is not null) + { + var item = _libraryManager.GetItemById(request.ItemId); + + SetDeviceSpecificData( + item, + result.MediaSource, + profile, + httpContext.User, + request.MaxStreamingBitrate, + request.StartTimeTicks ?? 0, + result.MediaSource.Id, + request.AudioStreamIndex, + request.SubtitleStreamIndex, + request.MaxAudioChannels, + request.PlaySessionId, + request.UserId, + request.EnableDirectPlay, + request.EnableDirectStream, + true, + true, + true, + httpContext.GetNormalizedRemoteIp()); + } + else { - var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken); - mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex; + if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) + { + result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; + } + } + + // here was a check if (result.MediaSource is not null) but Rider said it will never be null + NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video); - mediaSource.TranscodeReasons = info.TranscodeReasons; + return result; + } - foreach (var profile in profiles) + /// <summary> + /// Normalize media source container. + /// </summary> + /// <param name="mediaSource">Media source.</param> + /// <param name="profile">Device profile.</param> + /// <param name="type">Dlna profile type.</param> + public void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) + { + mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, profile, type); + } + + private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) + { + var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken); + mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex; + + mediaSource.TranscodeReasons = info.TranscodeReasons; + + foreach (var profile in profiles) + { + foreach (var stream in mediaSource.MediaStreams) { - foreach (var stream in mediaSource.MediaStreams) + if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index) { - if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index) - { - stream.DeliveryMethod = profile.DeliveryMethod; + stream.DeliveryMethod = profile.DeliveryMethod; - if (profile.DeliveryMethod == SubtitleDeliveryMethod.External) - { - stream.DeliveryUrl = profile.Url.TrimStart('-'); - stream.IsExternalUrl = profile.IsExternalUrl; - } + if (profile.DeliveryMethod == SubtitleDeliveryMethod.External) + { + stream.DeliveryUrl = profile.Url.TrimStart('-'); + stream.IsExternalUrl = profile.IsExternalUrl; } } } } + } + + private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress) + { + var maxBitrate = clientMaxBitrate; + var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0; - private int? GetMaxBitrate(int? clientMaxBitrate, User user, IPAddress ipAddress) + if (remoteClientMaxBitrate <= 0) { - var maxBitrate = clientMaxBitrate; - var remoteClientMaxBitrate = user.RemoteClientBitrateLimit ?? 0; + remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit; + } - if (remoteClientMaxBitrate <= 0) - { - remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit; - } + if (remoteClientMaxBitrate > 0) + { + var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress); - if (remoteClientMaxBitrate > 0) + _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork); + if (!isInLocalNetwork) { - var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress); - - _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork); - if (!isInLocalNetwork) - { - maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); - } + maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); } - - return maxBitrate; } + + return maxBitrate; } } diff --git a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs index dfeeea2b0..d7b1c9f8b 100644 --- a/Jellyfin.Api/Helpers/ProgressiveFileStream.cs +++ b/Jellyfin.Api/Helpers/ProgressiveFileStream.cs @@ -6,178 +6,177 @@ using System.Threading.Tasks; using Jellyfin.Api.Models.PlaybackDtos; using MediaBrowser.Model.IO; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// A progressive file stream for transferring transcoded files as they are written to. +/// </summary> +public class ProgressiveFileStream : Stream { + private readonly Stream _stream; + private readonly TranscodingJobDto? _job; + private readonly TranscodingJobHelper? _transcodingJobHelper; + private readonly int _timeoutMs; + private bool _disposed; + /// <summary> - /// A progressive file stream for transferring transcoded files as they are written to. + /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. /// </summary> - public class ProgressiveFileStream : Stream + /// <param name="filePath">The path to the transcoded file.</param> + /// <param name="job">The transcoding job information.</param> + /// <param name="transcodingJobHelper">The transcoding job helper.</param> + /// <param name="timeoutMs">The timeout duration in milliseconds.</param> + public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000) { - private readonly Stream _stream; - private readonly TranscodingJobDto? _job; - private readonly TranscodingJobHelper? _transcodingJobHelper; - private readonly int _timeoutMs; - private bool _disposed; - - /// <summary> - /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. - /// </summary> - /// <param name="filePath">The path to the transcoded file.</param> - /// <param name="job">The transcoding job information.</param> - /// <param name="transcodingJobHelper">The transcoding job helper.</param> - /// <param name="timeoutMs">The timeout duration in milliseconds.</param> - public ProgressiveFileStream(string filePath, TranscodingJobDto? job, TranscodingJobHelper transcodingJobHelper, int timeoutMs = 30000) - { - _job = job; - _transcodingJobHelper = transcodingJobHelper; - _timeoutMs = timeoutMs; + _job = job; + _transcodingJobHelper = transcodingJobHelper; + _timeoutMs = timeoutMs; - _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); - } + _stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + } - /// <summary> - /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. - /// </summary> - /// <param name="stream">The stream to progressively copy.</param> - /// <param name="timeoutMs">The timeout duration in milliseconds.</param> - public ProgressiveFileStream(Stream stream, int timeoutMs = 30000) - { - _job = null; - _transcodingJobHelper = null; - _timeoutMs = timeoutMs; - _stream = stream; - } + /// <summary> + /// Initializes a new instance of the <see cref="ProgressiveFileStream"/> class. + /// </summary> + /// <param name="stream">The stream to progressively copy.</param> + /// <param name="timeoutMs">The timeout duration in milliseconds.</param> + public ProgressiveFileStream(Stream stream, int timeoutMs = 30000) + { + _job = null; + _transcodingJobHelper = null; + _timeoutMs = timeoutMs; + _stream = stream; + } - /// <inheritdoc /> - public override bool CanRead => _stream.CanRead; + /// <inheritdoc /> + public override bool CanRead => _stream.CanRead; - /// <inheritdoc /> - public override bool CanSeek => false; + /// <inheritdoc /> + public override bool CanSeek => false; - /// <inheritdoc /> - public override bool CanWrite => false; + /// <inheritdoc /> + public override bool CanWrite => false; - /// <inheritdoc /> - public override long Length => throw new NotSupportedException(); + /// <inheritdoc /> + public override long Length => throw new NotSupportedException(); - /// <inheritdoc /> - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } + /// <inheritdoc /> + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } - /// <inheritdoc /> - public override void Flush() - { - // Not supported - } + /// <inheritdoc /> + public override void Flush() + { + // Not supported + } - /// <inheritdoc /> - public override int Read(byte[] buffer, int offset, int count) - => Read(buffer.AsSpan(offset, count)); + /// <inheritdoc /> + public override int Read(byte[] buffer, int offset, int count) + => Read(buffer.AsSpan(offset, count)); - /// <inheritdoc /> - public override int Read(Span<byte> buffer) - { - int totalBytesRead = 0; - var stopwatch = Stopwatch.StartNew(); + /// <inheritdoc /> + public override int Read(Span<byte> buffer) + { + int totalBytesRead = 0; + var stopwatch = Stopwatch.StartNew(); - while (true) + while (true) + { + totalBytesRead += _stream.Read(buffer); + if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) { - totalBytesRead += _stream.Read(buffer); - if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) - { - break; - } - - Thread.Sleep(50); + break; } - UpdateBytesWritten(totalBytesRead); - - return totalBytesRead; + Thread.Sleep(50); } - /// <inheritdoc /> - public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + UpdateBytesWritten(totalBytesRead); - /// <inheritdoc /> - public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) - { - int totalBytesRead = 0; - var stopwatch = Stopwatch.StartNew(); + return totalBytesRead; + } - while (true) - { - totalBytesRead += await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) - { - break; - } + /// <inheritdoc /> + public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); - await Task.Delay(50, cancellationToken).ConfigureAwait(false); - } + /// <inheritdoc /> + public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) + { + int totalBytesRead = 0; + var stopwatch = Stopwatch.StartNew(); - UpdateBytesWritten(totalBytesRead); + while (true) + { + totalBytesRead += await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (StopReading(totalBytesRead, stopwatch.ElapsedMilliseconds)) + { + break; + } - return totalBytesRead; + await Task.Delay(50, cancellationToken).ConfigureAwait(false); } - /// <inheritdoc /> - public override long Seek(long offset, SeekOrigin origin) - => throw new NotSupportedException(); + UpdateBytesWritten(totalBytesRead); + + return totalBytesRead; + } + + /// <inheritdoc /> + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); - /// <inheritdoc /> - public override void SetLength(long value) - => throw new NotSupportedException(); + /// <inheritdoc /> + public override void SetLength(long value) + => throw new NotSupportedException(); - /// <inheritdoc /> - public override void Write(byte[] buffer, int offset, int count) - => throw new NotSupportedException(); + /// <inheritdoc /> + public override void Write(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); - /// <inheritdoc /> - protected override void Dispose(bool disposing) + /// <inheritdoc /> + protected override void Dispose(bool disposing) + { + if (_disposed) { - if (_disposed) - { - return; - } + return; + } - try + try + { + if (disposing) { - if (disposing) - { - _stream.Dispose(); + _stream.Dispose(); - if (_job is not null) - { - _transcodingJobHelper?.OnTranscodeEndRequest(_job); - } + if (_job is not null) + { + _transcodingJobHelper?.OnTranscodeEndRequest(_job); } } - finally - { - _disposed = true; - base.Dispose(disposing); - } } - - private void UpdateBytesWritten(int totalBytesRead) + finally { - if (_job is not null) - { - _job.BytesDownloaded += totalBytesRead; - } + _disposed = true; + base.Dispose(disposing); } + } - private bool StopReading(int bytesRead, long elapsed) + private void UpdateBytesWritten(int totalBytesRead) + { + if (_job is not null) { - // It should stop reading when anything has been successfully read or if the job has exited - // If the job is null, however, it's a live stream and will require user action to close, - // but don't keep it open indefinitely if it isn't reading anything - return bytesRead > 0 || (_job?.HasExited ?? elapsed >= _timeoutMs); + _job.BytesDownloaded += totalBytesRead; } } + + private bool StopReading(int bytesRead, long elapsed) + { + // It should stop reading when anything has been successfully read or if the job has exited + // If the job is null, however, it's a live stream and will require user action to close, + // but don't keep it open indefinitely if it isn't reading anything + return bytesRead > 0 || (_job?.HasExited ?? elapsed >= _timeoutMs); + } } diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 035d84513..3ce2b834d 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -16,133 +16,132 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Request Extensions. +/// </summary> +public static class RequestHelpers { /// <summary> - /// Request Extensions. + /// Get Order By. /// </summary> - public static class RequestHelpers + /// <param name="sortBy">Sort By. Comma delimited string.</param> + /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param> + /// <returns>Order By.</returns> + public static (string, SortOrder)[] GetOrderBy(IReadOnlyList<string> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) { - /// <summary> - /// Get Order By. - /// </summary> - /// <param name="sortBy">Sort By. Comma delimited string.</param> - /// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param> - /// <returns>Order By.</returns> - public static (string, SortOrder)[] GetOrderBy(IReadOnlyList<string> sortBy, IReadOnlyList<SortOrder> requestedSortOrder) + if (sortBy.Count == 0) { - if (sortBy.Count == 0) - { - return Array.Empty<(string, SortOrder)>(); - } - - var result = new (string, SortOrder)[sortBy.Count]; - var i = 0; - // Add elements which have a SortOrder specified - for (; i < requestedSortOrder.Count; i++) - { - result[i] = (sortBy[i], requestedSortOrder[i]); - } - - // Add remaining elements with the first specified SortOrder - // or the default one if no SortOrders are specified - var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending; - for (; i < sortBy.Count; i++) - { - result[i] = (sortBy[i], order); - } + return Array.Empty<(string, SortOrder)>(); + } - return result; + var result = new (string, SortOrder)[sortBy.Count]; + var i = 0; + // Add elements which have a SortOrder specified + for (; i < requestedSortOrder.Count; i++) + { + result[i] = (sortBy[i], requestedSortOrder[i]); } - /// <summary> - /// Checks if the user can update an entry. - /// </summary> - /// <param name="userManager">An instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="claimsPrincipal">The <see cref="ClaimsPrincipal"/> for the current request.</param> - /// <param name="userId">The user id.</param> - /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param> - /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns> - internal static bool AssertCanUpdateUser(IUserManager userManager, ClaimsPrincipal claimsPrincipal, Guid userId, bool restrictUserPreferences) + // Add remaining elements with the first specified SortOrder + // or the default one if no SortOrders are specified + var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending; + for (; i < sortBy.Count; i++) { - var authenticatedUserId = claimsPrincipal.GetUserId(); - var isAdministrator = claimsPrincipal.IsInRole(UserRoles.Administrator); + result[i] = (sortBy[i], order); + } - // If they're going to update the record of another user, they must be an administrator - if (!userId.Equals(authenticatedUserId) && !isAdministrator) - { - return false; - } + return result; + } - // TODO the EnableUserPreferenceAccess policy does not seem to be used elsewhere - if (!restrictUserPreferences || isAdministrator) - { - return true; - } + /// <summary> + /// Checks if the user can update an entry. + /// </summary> + /// <param name="userManager">An instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="claimsPrincipal">The <see cref="ClaimsPrincipal"/> for the current request.</param> + /// <param name="userId">The user id.</param> + /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param> + /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns> + internal static bool AssertCanUpdateUser(IUserManager userManager, ClaimsPrincipal claimsPrincipal, Guid userId, bool restrictUserPreferences) + { + var authenticatedUserId = claimsPrincipal.GetUserId(); + var isAdministrator = claimsPrincipal.IsInRole(UserRoles.Administrator); - var user = userManager.GetUserById(userId); - return user.EnableUserPreferenceAccess; + // If they're going to update the record of another user, they must be an administrator + if (!userId.Equals(authenticatedUserId) && !isAdministrator) + { + return false; } - internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext) + // TODO the EnableUserPreferenceAccess policy does not seem to be used elsewhere + if (!restrictUserPreferences || isAdministrator) { - var userId = httpContext.User.GetUserId(); - var user = userManager.GetUserById(userId); - var session = await sessionManager.LogSessionActivity( - httpContext.User.GetClient(), - httpContext.User.GetVersion(), - httpContext.User.GetDeviceId(), - httpContext.User.GetDevice(), - httpContext.GetNormalizedRemoteIp().ToString(), - user).ConfigureAwait(false); - - if (session is null) - { - throw new ArgumentException("Session not found."); - } - - return session; + return true; } - internal static async Task<string> GetSessionId(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext) - { - var session = await GetSession(sessionManager, userManager, httpContext).ConfigureAwait(false); + var user = userManager.GetUserById(userId); + return user.EnableUserPreferenceAccess; + } - return session.Id; + internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext) + { + var userId = httpContext.User.GetUserId(); + var user = userManager.GetUserById(userId); + var session = await sessionManager.LogSessionActivity( + httpContext.User.GetClient(), + httpContext.User.GetVersion(), + httpContext.User.GetDeviceId(), + httpContext.User.GetDevice(), + httpContext.GetNormalizedRemoteIp().ToString(), + user).ConfigureAwait(false); + + if (session is null) + { + throw new ArgumentException("Session not found."); } - internal static QueryResult<BaseItemDto> CreateQueryResult( - QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result, - DtoOptions dtoOptions, - IDtoService dtoService, - bool includeItemTypes, - User? user) + return session; + } + + internal static async Task<string> GetSessionId(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext) + { + var session = await GetSession(sessionManager, userManager, httpContext).ConfigureAwait(false); + + return session.Id; + } + + internal static QueryResult<BaseItemDto> CreateQueryResult( + QueryResult<(BaseItem Item, ItemCounts ItemCounts)> result, + DtoOptions dtoOptions, + IDtoService dtoService, + bool includeItemTypes, + User? user) + { + var dtos = result.Items.Select(i => { - var dtos = result.Items.Select(i => + var (baseItem, counts) = i; + var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); + + if (includeItemTypes) { - var (baseItem, counts) = i; - var dto = dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - - if (includeItemTypes) - { - dto.ChildCount = counts.ItemCount; - dto.ProgramCount = counts.ProgramCount; - dto.SeriesCount = counts.SeriesCount; - dto.EpisodeCount = counts.EpisodeCount; - dto.MovieCount = counts.MovieCount; - dto.TrailerCount = counts.TrailerCount; - dto.AlbumCount = counts.AlbumCount; - dto.SongCount = counts.SongCount; - dto.ArtistCount = counts.ArtistCount; - } - - return dto; - }); - - return new QueryResult<BaseItemDto>( - result.StartIndex, - result.TotalRecordCount, - dtos.ToArray()); - } + dto.ChildCount = counts.ItemCount; + dto.ProgramCount = counts.ProgramCount; + dto.SeriesCount = counts.SeriesCount; + dto.EpisodeCount = counts.EpisodeCount; + dto.MovieCount = counts.MovieCount; + dto.TrailerCount = counts.TrailerCount; + dto.AlbumCount = counts.AlbumCount; + dto.SongCount = counts.SongCount; + dto.ArtistCount = counts.ArtistCount; + } + + return dto; + }); + + return new QueryResult<BaseItemDto>( + result.StartIndex, + result.TotalRecordCount, + dtos.ToArray()); } } diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index d4fc9c020..d867df86e 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -22,761 +22,760 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// The streaming helpers. +/// </summary> +public static class StreamingHelpers { /// <summary> - /// The streaming helpers. + /// Gets the current streaming state. /// </summary> - public static class StreamingHelpers + /// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param> + /// <param name="httpContext">The <see cref="HttpContext"/>.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> + /// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param> + /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> + /// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns> + public static async Task<StreamState> GetStreamingState( + StreamingRequestDto streamingRequest, + HttpContext httpContext, + IMediaSourceManager mediaSourceManager, + IUserManager userManager, + ILibraryManager libraryManager, + IServerConfigurationManager serverConfigurationManager, + IMediaEncoder mediaEncoder, + EncodingHelper encodingHelper, + IDlnaManager dlnaManager, + IDeviceManager deviceManager, + TranscodingJobHelper transcodingJobHelper, + TranscodingJobType transcodingJobType, + CancellationToken cancellationToken) { - /// <summary> - /// Gets the current streaming state. - /// </summary> - /// <param name="streamingRequest">The <see cref="StreamingRequestDto"/>.</param> - /// <param name="httpContext">The <see cref="HttpContext"/>.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param> - /// <param name="transcodingJobHelper">Initialized <see cref="TranscodingJobHelper"/>.</param> - /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> - /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> - /// <returns>A <see cref="Task"/> containing the current <see cref="StreamState"/>.</returns> - public static async Task<StreamState> GetStreamingState( - StreamingRequestDto streamingRequest, - HttpContext httpContext, - IMediaSourceManager mediaSourceManager, - IUserManager userManager, - ILibraryManager libraryManager, - IServerConfigurationManager serverConfigurationManager, - IMediaEncoder mediaEncoder, - EncodingHelper encodingHelper, - IDlnaManager dlnaManager, - IDeviceManager deviceManager, - TranscodingJobHelper transcodingJobHelper, - TranscodingJobType transcodingJobType, - CancellationToken cancellationToken) - { - var httpRequest = httpContext.Request; - // Parse the DLNA time seek header - if (!streamingRequest.StartTimeTicks.HasValue) - { - var timeSeek = httpRequest.Headers["TimeSeekRange.dlna.org"]; + var httpRequest = httpContext.Request; + // Parse the DLNA time seek header + if (!streamingRequest.StartTimeTicks.HasValue) + { + var timeSeek = httpRequest.Headers["TimeSeekRange.dlna.org"]; - streamingRequest.StartTimeTicks = ParseTimeSeekHeader(timeSeek.ToString()); - } + streamingRequest.StartTimeTicks = ParseTimeSeekHeader(timeSeek.ToString()); + } - if (!string.IsNullOrWhiteSpace(streamingRequest.Params)) - { - ParseParams(streamingRequest); - } + if (!string.IsNullOrWhiteSpace(streamingRequest.Params)) + { + ParseParams(streamingRequest); + } - streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query); - if (httpRequest.Path.Value is null) - { - throw new ResourceNotFoundException(nameof(httpRequest.Path)); - } + streamingRequest.StreamOptions = ParseStreamOptions(httpRequest.Query); + if (httpRequest.Path.Value is null) + { + throw new ResourceNotFoundException(nameof(httpRequest.Path)); + } - var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString(); + var url = httpRequest.Path.Value.AsSpan().RightPart('.').ToString(); - if (string.IsNullOrEmpty(streamingRequest.AudioCodec)) - { - streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url); - } + if (string.IsNullOrEmpty(streamingRequest.AudioCodec)) + { + streamingRequest.AudioCodec = encodingHelper.InferAudioCodec(url); + } - var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) || - streamingRequest.StreamOptions.ContainsKey("dlnaheaders") || - string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase); + var enableDlnaHeaders = !string.IsNullOrWhiteSpace(streamingRequest.Params) || + streamingRequest.StreamOptions.ContainsKey("dlnaheaders") || + string.Equals(httpRequest.Headers["GetContentFeatures.DLNA.ORG"], "1", StringComparison.OrdinalIgnoreCase); - var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper) - { - Request = streamingRequest, - RequestedUrl = url, - UserAgent = httpRequest.Headers[HeaderNames.UserAgent], - EnableDlnaHeaders = enableDlnaHeaders - }; - - var userId = httpContext.User.GetUserId(); - if (!userId.Equals(default)) - { - state.User = userManager.GetUserById(userId); - } + var state = new StreamState(mediaSourceManager, transcodingJobType, transcodingJobHelper) + { + Request = streamingRequest, + RequestedUrl = url, + UserAgent = httpRequest.Headers[HeaderNames.UserAgent], + EnableDlnaHeaders = enableDlnaHeaders + }; + + var userId = httpContext.User.GetUserId(); + if (!userId.Equals(default)) + { + state.User = userManager.GetUserById(userId); + } - if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec)) - { - state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); - state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); - } + if (state.IsVideoRequest && !string.IsNullOrWhiteSpace(state.Request.VideoCodec)) + { + state.SupportedVideoCodecs = state.Request.VideoCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); + state.Request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); + } - if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec)) - { - state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); - state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec) - ?? state.SupportedAudioCodecs.FirstOrDefault(); - } + if (!string.IsNullOrWhiteSpace(streamingRequest.AudioCodec)) + { + state.SupportedAudioCodecs = streamingRequest.AudioCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); + state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(mediaEncoder.CanEncodeToAudioCodec) + ?? state.SupportedAudioCodecs.FirstOrDefault(); + } - if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec)) - { - state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); - state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(mediaEncoder.CanEncodeToSubtitleCodec) - ?? state.SupportedSubtitleCodecs.FirstOrDefault(); - } + if (!string.IsNullOrWhiteSpace(streamingRequest.SubtitleCodec)) + { + state.SupportedSubtitleCodecs = streamingRequest.SubtitleCodec.Split(',', StringSplitOptions.RemoveEmptyEntries); + state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(mediaEncoder.CanEncodeToSubtitleCodec) + ?? state.SupportedSubtitleCodecs.FirstOrDefault(); + } - var item = libraryManager.GetItemById(streamingRequest.Id); + var item = libraryManager.GetItemById(streamingRequest.Id); - state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); + state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); - MediaSourceInfo? mediaSource = null; - if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)) - { - var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId) - ? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId) - : null; + MediaSourceInfo? mediaSource = null; + if (string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)) + { + var currentJob = !string.IsNullOrWhiteSpace(streamingRequest.PlaySessionId) + ? transcodingJobHelper.GetTranscodingJob(streamingRequest.PlaySessionId) + : null; - if (currentJob is not null) - { - mediaSource = currentJob.MediaSource; - } + if (currentJob is not null) + { + mediaSource = currentJob.MediaSource; + } - if (mediaSource is null) - { - var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false); + if (mediaSource is null) + { + var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false); - mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId) - ? mediaSources[0] - : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal)); + mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId) + ? mediaSources[0] + : mediaSources.Find(i => string.Equals(i.Id, streamingRequest.MediaSourceId, StringComparison.Ordinal)); - if (mediaSource is null && Guid.Parse(streamingRequest.MediaSourceId).Equals(streamingRequest.Id)) - { - mediaSource = mediaSources[0]; - } + if (mediaSource is null && Guid.Parse(streamingRequest.MediaSourceId).Equals(streamingRequest.Id)) + { + mediaSource = mediaSources[0]; } } - else - { - var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false); - mediaSource = liveStreamInfo.Item1; - state.DirectStreamProvider = liveStreamInfo.Item2; - } + } + else + { + var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false); + mediaSource = liveStreamInfo.Item1; + state.DirectStreamProvider = liveStreamInfo.Item2; + } - var encodingOptions = serverConfigurationManager.GetEncodingOptions(); + var encodingOptions = serverConfigurationManager.GetEncodingOptions(); - encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url); + encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url); - string? containerInternal = Path.GetExtension(state.RequestedUrl); + string? containerInternal = Path.GetExtension(state.RequestedUrl); - if (!string.IsNullOrEmpty(streamingRequest.Container)) - { - containerInternal = streamingRequest.Container; - } + if (!string.IsNullOrEmpty(streamingRequest.Container)) + { + containerInternal = streamingRequest.Container; + } - if (string.IsNullOrEmpty(containerInternal)) - { - containerInternal = streamingRequest.Static ? - StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, null, DlnaProfileType.Audio) - : GetOutputFileExtension(state, mediaSource); - } + if (string.IsNullOrEmpty(containerInternal)) + { + containerInternal = streamingRequest.Static ? + StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, null, DlnaProfileType.Audio) + : GetOutputFileExtension(state, mediaSource); + } - state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); + state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); - state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream); + state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream); - state.OutputAudioCodec = streamingRequest.AudioCodec; + state.OutputAudioCodec = streamingRequest.AudioCodec; - state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); + state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); - if (state.VideoRequest is not null) - { - state.OutputVideoCodec = state.Request.VideoCodec; - state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); + if (state.VideoRequest is not null) + { + state.OutputVideoCodec = state.Request.VideoCodec; + state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - encodingHelper.TryStreamCopy(state); + encodingHelper.TryStreamCopy(state); - if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) + if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) + { + var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue + && !state.VideoRequest.Height.HasValue + && !state.VideoRequest.MaxWidth.HasValue + && !state.VideoRequest.MaxHeight.HasValue; + + if (isVideoResolutionNotRequested + && state.VideoStream is not null + && state.VideoRequest.VideoBitRate.HasValue + && state.VideoStream.BitRate.HasValue + && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value) { - var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue - && !state.VideoRequest.Height.HasValue - && !state.VideoRequest.MaxWidth.HasValue - && !state.VideoRequest.MaxHeight.HasValue; - - if (isVideoResolutionNotRequested - && state.VideoStream is not null - && state.VideoRequest.VideoBitRate.HasValue - && state.VideoStream.BitRate.HasValue - && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value) - { - // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested, - // and the requested video bitrate is higher than source video bitrate. - if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue) - { - state.VideoRequest.MaxWidth = state.VideoStream?.Width; - state.VideoRequest.MaxHeight = state.VideoStream?.Height; - } - } - else + // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested, + // and the requested video bitrate is higher than source video bitrate. + if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue) { - var resolution = ResolutionNormalizer.Normalize( - state.VideoStream?.BitRate, - state.OutputVideoBitrate.Value, - state.VideoRequest.MaxWidth, - state.VideoRequest.MaxHeight); - - state.VideoRequest.MaxWidth = resolution.MaxWidth; - state.VideoRequest.MaxHeight = resolution.MaxHeight; + state.VideoRequest.MaxWidth = state.VideoStream?.Width; + state.VideoRequest.MaxHeight = state.VideoStream?.Height; } } + else + { + var resolution = ResolutionNormalizer.Normalize( + state.VideoStream?.BitRate, + state.OutputVideoBitrate.Value, + state.VideoRequest.MaxWidth, + state.VideoRequest.MaxHeight); + + state.VideoRequest.MaxWidth = resolution.MaxWidth; + state.VideoRequest.MaxHeight = resolution.MaxHeight; + } } + } - ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, httpRequest, streamingRequest.DeviceProfileId, streamingRequest.Static); + ApplyDeviceProfileSettings(state, dlnaManager, deviceManager, httpRequest, streamingRequest.DeviceProfileId, streamingRequest.Static); - var ext = string.IsNullOrWhiteSpace(state.OutputContainer) - ? GetOutputFileExtension(state, mediaSource) - : ("." + state.OutputContainer); + var ext = string.IsNullOrWhiteSpace(state.OutputContainer) + ? GetOutputFileExtension(state, mediaSource) + : ("." + state.OutputContainer); - state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId); + state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId); - return state; - } + return state; + } - /// <summary> - /// Adds the dlna headers. - /// </summary> - /// <param name="state">The state.</param> - /// <param name="responseHeaders">The response headers.</param> - /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param> - /// <param name="startTimeTicks">The start time in ticks.</param> - /// <param name="request">The <see cref="HttpRequest"/>.</param> - /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - public static void AddDlnaHeaders( - StreamState state, - IHeaderDictionary responseHeaders, - bool isStaticallyStreamed, - long? startTimeTicks, - HttpRequest request, - IDlnaManager dlnaManager) + /// <summary> + /// Adds the dlna headers. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="responseHeaders">The response headers.</param> + /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + /// <param name="request">The <see cref="HttpRequest"/>.</param> + /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> + public static void AddDlnaHeaders( + StreamState state, + IHeaderDictionary responseHeaders, + bool isStaticallyStreamed, + long? startTimeTicks, + HttpRequest request, + IDlnaManager dlnaManager) + { + if (!state.EnableDlnaHeaders) { - if (!state.EnableDlnaHeaders) - { - return; - } + return; + } - var profile = state.DeviceProfile; + var profile = state.DeviceProfile; - StringValues transferMode = request.Headers["transferMode.dlna.org"]; - responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString()); - responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*"); + StringValues transferMode = request.Headers["transferMode.dlna.org"]; + responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString()); + responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*"); - if (state.RunTimeTicks.HasValue) + if (state.RunTimeTicks.HasValue) + { + if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase)) - { - var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds; - responseHeaders.Add("MediaInfo.sec", string.Format( - CultureInfo.InvariantCulture, - "SEC_Duration={0};", - Convert.ToInt32(ms))); - } + var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds; + responseHeaders.Add("MediaInfo.sec", string.Format( + CultureInfo.InvariantCulture, + "SEC_Duration={0};", + Convert.ToInt32(ms))); + } - if (!isStaticallyStreamed && profile is not null) - { - AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks); - } + if (!isStaticallyStreamed && profile is not null) + { + AddTimeSeekResponseHeaders(state, responseHeaders, startTimeTicks); } + } - profile ??= dlnaManager.GetDefaultProfile(); + profile ??= dlnaManager.GetDefaultProfile(); - var audioCodec = state.ActualOutputAudioCodec; + var audioCodec = state.ActualOutputAudioCodec; - if (!state.IsVideoRequest) - { - responseHeaders.Add("contentFeatures.dlna.org", ContentFeatureBuilder.BuildAudioHeader( - profile, - state.OutputContainer, - audioCodec, - state.OutputAudioBitrate, - state.OutputAudioSampleRate, - state.OutputAudioChannels, - state.OutputAudioBitDepth, - isStaticallyStreamed, - state.RunTimeTicks, - state.TranscodeSeekInfo)); - } - else - { - var videoCodec = state.ActualOutputVideoCodec; + if (!state.IsVideoRequest) + { + responseHeaders.Add("contentFeatures.dlna.org", ContentFeatureBuilder.BuildAudioHeader( + profile, + state.OutputContainer, + audioCodec, + state.OutputAudioBitrate, + state.OutputAudioSampleRate, + state.OutputAudioChannels, + state.OutputAudioBitDepth, + isStaticallyStreamed, + state.RunTimeTicks, + state.TranscodeSeekInfo)); + } + else + { + var videoCodec = state.ActualOutputVideoCodec; - responseHeaders.Add( - "contentFeatures.dlna.org", - ContentFeatureBuilder.BuildVideoHeader(profile, state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoRangeType, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty); - } + responseHeaders.Add( + "contentFeatures.dlna.org", + ContentFeatureBuilder.BuildVideoHeader(profile, state.OutputContainer, videoCodec, audioCodec, state.OutputWidth, state.OutputHeight, state.TargetVideoBitDepth, state.OutputVideoBitrate, state.TargetTimestamp, isStaticallyStreamed, state.RunTimeTicks, state.TargetVideoProfile, state.TargetVideoRangeType, state.TargetVideoLevel, state.TargetFramerate, state.TargetPacketLength, state.TranscodeSeekInfo, state.IsTargetAnamorphic, state.IsTargetInterlaced, state.TargetRefFrames, state.TargetVideoStreamCount, state.TargetAudioStreamCount, state.TargetVideoCodecTag, state.IsTargetAVC).FirstOrDefault() ?? string.Empty); } + } - /// <summary> - /// Parses the time seek header. - /// </summary> - /// <param name="value">The time seek header string.</param> - /// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns> - private static long? ParseTimeSeekHeader(ReadOnlySpan<char> value) + /// <summary> + /// Parses the time seek header. + /// </summary> + /// <param name="value">The time seek header string.</param> + /// <returns>A nullable <see cref="long"/> representing the seek time in ticks.</returns> + private static long? ParseTimeSeekHeader(ReadOnlySpan<char> value) + { + if (value.IsEmpty) { - if (value.IsEmpty) - { - return null; - } + return null; + } - const string npt = "npt="; - if (!value.StartsWith(npt, StringComparison.OrdinalIgnoreCase)) - { - throw new ArgumentException("Invalid timeseek header"); - } + const string npt = "npt="; + if (!value.StartsWith(npt, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid timeseek header"); + } - var index = value.IndexOf('-'); - value = index == -1 - ? value.Slice(npt.Length) - : value.Slice(npt.Length, index - npt.Length); - if (value.IndexOf(':') == -1) + var index = value.IndexOf('-'); + value = index == -1 + ? value.Slice(npt.Length) + : value.Slice(npt.Length, index - npt.Length); + if (value.IndexOf(':') == -1) + { + // Parses npt times in the format of '417.33' + if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) { - // Parses npt times in the format of '417.33' - if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) - { - return TimeSpan.FromSeconds(seconds).Ticks; - } - - throw new ArgumentException("Invalid timeseek header"); + return TimeSpan.FromSeconds(seconds).Ticks; } - try - { - // Parses npt times in the format of '10:19:25.7' - return TimeSpan.Parse(value, CultureInfo.InvariantCulture).Ticks; - } - catch - { - throw new ArgumentException("Invalid timeseek header"); - } + throw new ArgumentException("Invalid timeseek header"); } - /// <summary> - /// Parses query parameters as StreamOptions. - /// </summary> - /// <param name="queryString">The query string.</param> - /// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns> - private static Dictionary<string, string?> ParseStreamOptions(IQueryCollection queryString) + try + { + // Parses npt times in the format of '10:19:25.7' + return TimeSpan.Parse(value, CultureInfo.InvariantCulture).Ticks; + } + catch + { + throw new ArgumentException("Invalid timeseek header"); + } + } + + /// <summary> + /// Parses query parameters as StreamOptions. + /// </summary> + /// <param name="queryString">The query string.</param> + /// <returns>A <see cref="Dictionary{String,String}"/> containing the stream options.</returns> + private static Dictionary<string, string?> ParseStreamOptions(IQueryCollection queryString) + { + Dictionary<string, string?> streamOptions = new Dictionary<string, string?>(); + foreach (var param in queryString) { - Dictionary<string, string?> streamOptions = new Dictionary<string, string?>(); - foreach (var param in queryString) + if (char.IsLower(param.Key[0])) { - if (char.IsLower(param.Key[0])) - { - // This was probably not parsed initially and should be a StreamOptions - // or the generated URL should correctly serialize it - // TODO: This should be incorporated either in the lower framework for parsing requests - streamOptions[param.Key] = param.Value; - } + // This was probably not parsed initially and should be a StreamOptions + // or the generated URL should correctly serialize it + // TODO: This should be incorporated either in the lower framework for parsing requests + streamOptions[param.Key] = param.Value; } - - return streamOptions; } - /// <summary> - /// Adds the dlna time seek headers to the response. - /// </summary> - /// <param name="state">The current <see cref="StreamState"/>.</param> - /// <param name="responseHeaders">The <see cref="IHeaderDictionary"/> of the response.</param> - /// <param name="startTimeTicks">The start time in ticks.</param> - private static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks) - { - var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture); - var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture); + return streamOptions; + } - responseHeaders.Add("TimeSeekRange.dlna.org", string.Format( - CultureInfo.InvariantCulture, - "npt={0}-{1}/{1}", - startSeconds, - runtimeSeconds)); - responseHeaders.Add("X-AvailableSeekRange", string.Format( - CultureInfo.InvariantCulture, - "1 npt={0}-{1}", - startSeconds, - runtimeSeconds)); + /// <summary> + /// Adds the dlna time seek headers to the response. + /// </summary> + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="responseHeaders">The <see cref="IHeaderDictionary"/> of the response.</param> + /// <param name="startTimeTicks">The start time in ticks.</param> + private static void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders, long? startTimeTicks) + { + var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks!.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture); + var startSeconds = TimeSpan.FromTicks(startTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture); + + responseHeaders.Add("TimeSeekRange.dlna.org", string.Format( + CultureInfo.InvariantCulture, + "npt={0}-{1}/{1}", + startSeconds, + runtimeSeconds)); + responseHeaders.Add("X-AvailableSeekRange", string.Format( + CultureInfo.InvariantCulture, + "1 npt={0}-{1}", + startSeconds, + runtimeSeconds)); + } + + /// <summary> + /// Gets the output file extension. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="mediaSource">The mediaSource.</param> + /// <returns>System.String.</returns> + private static string? GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource) + { + var ext = Path.GetExtension(state.RequestedUrl); + + if (!string.IsNullOrEmpty(ext)) + { + return ext; } - /// <summary> - /// Gets the output file extension. - /// </summary> - /// <param name="state">The state.</param> - /// <param name="mediaSource">The mediaSource.</param> - /// <returns>System.String.</returns> - private static string? GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource) + // Try to infer based on the desired video codec + if (state.IsVideoRequest) { - var ext = Path.GetExtension(state.RequestedUrl); + var videoCodec = state.Request.VideoCodec; - if (!string.IsNullOrEmpty(ext)) + if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) || + string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) { - return ext; + return ".ts"; } - // Try to infer based on the desired video codec - if (state.IsVideoRequest) + if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase)) { - var videoCodec = state.Request.VideoCodec; - - if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase) || - string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) - { - return ".ts"; - } - - if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase)) - { - return ".ogv"; - } - - if (string.Equals(videoCodec, "vp8", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase) - || string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) - { - return ".webm"; - } - - if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase)) - { - return ".asf"; - } + return ".ogv"; } - // Try to infer based on the desired audio codec - if (!state.IsVideoRequest) + if (string.Equals(videoCodec, "vp8", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "vp9", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) { - var audioCodec = state.Request.AudioCodec; + return ".webm"; + } - if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".aac"; - } + if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase)) + { + return ".asf"; + } + } - if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".mp3"; - } + // Try to infer based on the desired audio codec + if (!state.IsVideoRequest) + { + var audioCodec = state.Request.AudioCodec; - if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".ogg"; - } + if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".aac"; + } - if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase)) - { - return ".wma"; - } + if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".mp3"; } - // Fallback to the container of mediaSource - if (!string.IsNullOrEmpty(mediaSource?.Container)) + if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase)) { - var idx = mediaSource.Container.IndexOf(',', StringComparison.OrdinalIgnoreCase); - return '.' + (idx == -1 ? mediaSource.Container : mediaSource.Container[..idx]).Trim(); + return ".ogg"; } - return null; + if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".wma"; + } } - /// <summary> - /// Gets the output file path for transcoding. - /// </summary> - /// <param name="state">The current <see cref="StreamState"/>.</param> - /// <param name="outputFileExtension">The file extension of the output file.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="deviceId">The device id.</param> - /// <param name="playSessionId">The play session id.</param> - /// <returns>The complete file path, including the folder, for the transcoding file.</returns> - private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId) + // Fallback to the container of mediaSource + if (!string.IsNullOrEmpty(mediaSource?.Container)) { - var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}"; + var idx = mediaSource.Container.IndexOf(',', StringComparison.OrdinalIgnoreCase); + return '.' + (idx == -1 ? mediaSource.Container : mediaSource.Container[..idx]).Trim(); + } - var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture); - var ext = outputFileExtension?.ToLowerInvariant(); - var folder = serverConfigurationManager.GetTranscodePath(); + return null; + } - return Path.Combine(folder, filename + ext); - } + /// <summary> + /// Gets the output file path for transcoding. + /// </summary> + /// <param name="state">The current <see cref="StreamState"/>.</param> + /// <param name="outputFileExtension">The file extension of the output file.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="deviceId">The device id.</param> + /// <param name="playSessionId">The play session id.</param> + /// <returns>The complete file path, including the folder, for the transcoding file.</returns> + private static string GetOutputFilePath(StreamState state, string outputFileExtension, IServerConfigurationManager serverConfigurationManager, string? deviceId, string? playSessionId) + { + var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}"; - private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static) - { - if (!string.IsNullOrWhiteSpace(deviceProfileId)) - { - state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId); + var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture); + var ext = outputFileExtension?.ToLowerInvariant(); + var folder = serverConfigurationManager.GetTranscodePath(); - if (state.DeviceProfile is null) - { - var caps = deviceManager.GetCapabilities(deviceProfileId); - state.DeviceProfile = caps is null ? dlnaManager.GetProfile(request.Headers) : caps.DeviceProfile; - } - } + return Path.Combine(folder, filename + ext); + } - var profile = state.DeviceProfile; + private static void ApplyDeviceProfileSettings(StreamState state, IDlnaManager dlnaManager, IDeviceManager deviceManager, HttpRequest request, string? deviceProfileId, bool? @static) + { + if (!string.IsNullOrWhiteSpace(deviceProfileId)) + { + state.DeviceProfile = dlnaManager.GetProfile(deviceProfileId); - if (profile is null) + if (state.DeviceProfile is null) { - // Don't use settings from the default profile. - // Only use a specific profile if it was requested. - return; + var caps = deviceManager.GetCapabilities(deviceProfileId); + state.DeviceProfile = caps is null ? dlnaManager.GetProfile(request.Headers) : caps.DeviceProfile; } + } - var audioCodec = state.ActualOutputAudioCodec; - var videoCodec = state.ActualOutputVideoCodec; + var profile = state.DeviceProfile; - var mediaProfile = !state.IsVideoRequest - ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth) - : profile.GetVideoMediaProfile( - state.OutputContainer, - audioCodec, - videoCodec, - state.OutputWidth, - state.OutputHeight, - state.TargetVideoBitDepth, - state.OutputVideoBitrate, - state.TargetVideoProfile, - state.TargetVideoRangeType, - state.TargetVideoLevel, - state.TargetFramerate, - state.TargetPacketLength, - state.TargetTimestamp, - state.IsTargetAnamorphic, - state.IsTargetInterlaced, - state.TargetRefFrames, - state.TargetVideoStreamCount, - state.TargetAudioStreamCount, - state.TargetVideoCodecTag, - state.IsTargetAVC); - - if (mediaProfile is not null) - { - state.MimeType = mediaProfile.MimeType; - } + if (profile is null) + { + // Don't use settings from the default profile. + // Only use a specific profile if it was requested. + return; + } - if (!(@static.HasValue && @static.Value)) + var audioCodec = state.ActualOutputAudioCodec; + var videoCodec = state.ActualOutputVideoCodec; + + var mediaProfile = !state.IsVideoRequest + ? profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth) + : profile.GetVideoMediaProfile( + state.OutputContainer, + audioCodec, + videoCodec, + state.OutputWidth, + state.OutputHeight, + state.TargetVideoBitDepth, + state.OutputVideoBitrate, + state.TargetVideoProfile, + state.TargetVideoRangeType, + state.TargetVideoLevel, + state.TargetFramerate, + state.TargetPacketLength, + state.TargetTimestamp, + state.IsTargetAnamorphic, + state.IsTargetInterlaced, + state.TargetRefFrames, + state.TargetVideoStreamCount, + state.TargetAudioStreamCount, + state.TargetVideoCodecTag, + state.IsTargetAVC); + + if (mediaProfile is not null) + { + state.MimeType = mediaProfile.MimeType; + } + + if (!(@static.HasValue && @static.Value)) + { + var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec); + + if (transcodingProfile is not null) { - var transcodingProfile = !state.IsVideoRequest ? profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec); + state.EstimateContentLength = transcodingProfile.EstimateContentLength; + // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; + state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; - if (transcodingProfile is not null) + if (state.VideoRequest is not null) { - state.EstimateContentLength = transcodingProfile.EstimateContentLength; - // state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; - state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; - - if (state.VideoRequest is not null) - { - state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps; - state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest; - } + state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps; + state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest; } } } + } - /// <summary> - /// Parses the parameters. - /// </summary> - /// <param name="request">The request.</param> - private static void ParseParams(StreamingRequestDto request) + /// <summary> + /// Parses the parameters. + /// </summary> + /// <param name="request">The request.</param> + private static void ParseParams(StreamingRequestDto request) + { + if (string.IsNullOrEmpty(request.Params)) { - if (string.IsNullOrEmpty(request.Params)) - { - return; - } + return; + } - var vals = request.Params.Split(';'); + var vals = request.Params.Split(';'); - var videoRequest = request as VideoRequestDto; + var videoRequest = request as VideoRequestDto; - for (var i = 0; i < vals.Length; i++) - { - var val = vals[i]; + for (var i = 0; i < vals.Length; i++) + { + var val = vals[i]; - if (string.IsNullOrWhiteSpace(val)) - { - continue; - } + if (string.IsNullOrWhiteSpace(val)) + { + continue; + } - switch (i) - { - case 0: - request.DeviceProfileId = val; - break; - case 1: - request.DeviceId = val; - break; - case 2: - request.MediaSourceId = val; - break; - case 3: - request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - break; - case 4: - if (videoRequest is not null) - { - videoRequest.VideoCodec = val; - } + switch (i) + { + case 0: + request.DeviceProfileId = val; + break; + case 1: + request.DeviceId = val; + break; + case 2: + request.MediaSourceId = val; + break; + case 3: + request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + break; + case 4: + if (videoRequest is not null) + { + videoRequest.VideoCodec = val; + } - break; - case 5: - request.AudioCodec = val; - break; - case 6: - if (videoRequest is not null) - { - videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 5: + request.AudioCodec = val; + break; + case 6: + if (videoRequest is not null) + { + videoRequest.AudioStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 7: - if (videoRequest is not null) - { - videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 7: + if (videoRequest is not null) + { + videoRequest.SubtitleStreamIndex = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 8: - if (videoRequest is not null) - { - videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 8: + if (videoRequest is not null) + { + videoRequest.VideoBitRate = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 9: - request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture); - break; - case 10: - request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); - break; - case 11: - if (videoRequest is not null) - { - videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 9: + request.AudioBitRate = int.Parse(val, CultureInfo.InvariantCulture); + break; + case 10: + request.MaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); + break; + case 11: + if (videoRequest is not null) + { + videoRequest.MaxFramerate = float.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 12: - if (videoRequest is not null) - { - videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 12: + if (videoRequest is not null) + { + videoRequest.MaxWidth = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 13: - if (videoRequest is not null) - { - videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 13: + if (videoRequest is not null) + { + videoRequest.MaxHeight = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 14: - request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); - break; - case 15: - if (videoRequest is not null) - { - videoRequest.Level = val; - } + break; + case 14: + request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); + break; + case 15: + if (videoRequest is not null) + { + videoRequest.Level = val; + } - break; - case 16: - if (videoRequest is not null) - { - videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 16: + if (videoRequest is not null) + { + videoRequest.MaxRefFrames = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 17: - if (videoRequest is not null) - { - videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture); - } + break; + case 17: + if (videoRequest is not null) + { + videoRequest.MaxVideoBitDepth = int.Parse(val, CultureInfo.InvariantCulture); + } - break; - case 18: - if (videoRequest is not null) - { - videoRequest.Profile = val; - } + break; + case 18: + if (videoRequest is not null) + { + videoRequest.Profile = val; + } - break; - case 19: - // cabac no longer used - break; - case 20: - request.PlaySessionId = val; - break; - case 21: - // api_key - break; - case 22: - request.LiveStreamId = val; - break; - case 23: - // Duplicating ItemId because of MediaMonkey - break; - case 24: - if (videoRequest is not null) - { - videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } + break; + case 19: + // cabac no longer used + break; + case 20: + request.PlaySessionId = val; + break; + case 21: + // api_key + break; + case 22: + request.LiveStreamId = val; + break; + case 23: + // Duplicating ItemId because of MediaMonkey + break; + case 24: + if (videoRequest is not null) + { + videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } - break; - case 25: - if (!string.IsNullOrWhiteSpace(val) && videoRequest is not null) + break; + case 25: + if (!string.IsNullOrWhiteSpace(val) && videoRequest is not null) + { + if (Enum.TryParse(val, out SubtitleDeliveryMethod method)) { - if (Enum.TryParse(val, out SubtitleDeliveryMethod method)) - { - videoRequest.SubtitleMethod = method; - } + videoRequest.SubtitleMethod = method; } + } - break; - case 26: - request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); - break; - case 27: - if (videoRequest is not null) - { - videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } + break; + case 26: + request.TranscodingMaxAudioChannels = int.Parse(val, CultureInfo.InvariantCulture); + break; + case 27: + if (videoRequest is not null) + { + videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } - break; - case 28: - request.Tag = val; - break; - case 29: - if (videoRequest is not null) - { - videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } + break; + case 28: + request.Tag = val; + break; + case 29: + if (videoRequest is not null) + { + videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } - break; - case 30: - request.SubtitleCodec = val; - break; - case 31: - if (videoRequest is not null) - { - videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } + break; + case 30: + request.SubtitleCodec = val; + break; + case 31: + if (videoRequest is not null) + { + videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } - break; - case 32: - if (videoRequest is not null) - { - videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } + break; + case 32: + if (videoRequest is not null) + { + videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } - break; - case 33: - request.TranscodeReasons = val; - break; - } + break; + case 33: + request.TranscodeReasons = val; + break; } } } diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 77dd51860..12960f87a 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -27,888 +27,887 @@ using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Helpers +namespace Jellyfin.Api.Helpers; + +/// <summary> +/// Transcoding job helpers. +/// </summary> +public class TranscodingJobHelper : IDisposable { /// <summary> - /// Transcoding job helpers. + /// The active transcoding jobs. + /// </summary> + private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>(); + + /// <summary> + /// The transcoding locks. /// </summary> - public class TranscodingJobHelper : IDisposable + private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>(); + + private readonly IAttachmentExtractor _attachmentExtractor; + private readonly IApplicationPaths _appPaths; + private readonly EncodingHelper _encodingHelper; + private readonly IFileSystem _fileSystem; + private readonly ILogger<TranscodingJobHelper> _logger; + private readonly IMediaEncoder _mediaEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly ISessionManager _sessionManager; + private readonly ILoggerFactory _loggerFactory; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class. + /// </summary> + /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param> + /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public TranscodingJobHelper( + IAttachmentExtractor attachmentExtractor, + IApplicationPaths appPaths, + ILogger<TranscodingJobHelper> logger, + IMediaSourceManager mediaSourceManager, + IFileSystem fileSystem, + IMediaEncoder mediaEncoder, + IServerConfigurationManager serverConfigurationManager, + ISessionManager sessionManager, + EncodingHelper encodingHelper, + ILoggerFactory loggerFactory, + IUserManager userManager) { - /// <summary> - /// The active transcoding jobs. - /// </summary> - private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>(); - - /// <summary> - /// The transcoding locks. - /// </summary> - private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>(); - - private readonly IAttachmentExtractor _attachmentExtractor; - private readonly IApplicationPaths _appPaths; - private readonly EncodingHelper _encodingHelper; - private readonly IFileSystem _fileSystem; - private readonly ILogger<TranscodingJobHelper> _logger; - private readonly IMediaEncoder _mediaEncoder; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly ISessionManager _sessionManager; - private readonly ILoggerFactory _loggerFactory; - private readonly IUserManager _userManager; - - /// <summary> - /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class. - /// </summary> - /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param> - /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> - /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - /// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param> - /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public TranscodingJobHelper( - IAttachmentExtractor attachmentExtractor, - IApplicationPaths appPaths, - ILogger<TranscodingJobHelper> logger, - IMediaSourceManager mediaSourceManager, - IFileSystem fileSystem, - IMediaEncoder mediaEncoder, - IServerConfigurationManager serverConfigurationManager, - ISessionManager sessionManager, - EncodingHelper encodingHelper, - ILoggerFactory loggerFactory, - IUserManager userManager) - { - _attachmentExtractor = attachmentExtractor; - _appPaths = appPaths; - _logger = logger; - _mediaSourceManager = mediaSourceManager; - _fileSystem = fileSystem; - _mediaEncoder = mediaEncoder; - _serverConfigurationManager = serverConfigurationManager; - _sessionManager = sessionManager; - _encodingHelper = encodingHelper; - _loggerFactory = loggerFactory; - _userManager = userManager; - - DeleteEncodedMediaCache(); - - sessionManager.PlaybackProgress += OnPlaybackProgress; - sessionManager.PlaybackStart += OnPlaybackProgress; - } - - /// <summary> - /// Get transcoding job. - /// </summary> - /// <param name="playSessionId">Playback session id.</param> - /// <returns>The transcoding job.</returns> - public TranscodingJobDto? GetTranscodingJob(string playSessionId) - { - lock (_activeTranscodingJobs) - { - return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase)); - } - } + _attachmentExtractor = attachmentExtractor; + _appPaths = appPaths; + _logger = logger; + _mediaSourceManager = mediaSourceManager; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + _serverConfigurationManager = serverConfigurationManager; + _sessionManager = sessionManager; + _encodingHelper = encodingHelper; + _loggerFactory = loggerFactory; + _userManager = userManager; + + DeleteEncodedMediaCache(); + + sessionManager.PlaybackProgress += OnPlaybackProgress; + sessionManager.PlaybackStart += OnPlaybackProgress; + } - /// <summary> - /// Get transcoding job. - /// </summary> - /// <param name="path">Path to the transcoding file.</param> - /// <param name="type">The <see cref="TranscodingJobType"/>.</param> - /// <returns>The transcoding job.</returns> - public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type) + /// <summary> + /// Get transcoding job. + /// </summary> + /// <param name="playSessionId">Playback session id.</param> + /// <returns>The transcoding job.</returns> + public TranscodingJobDto? GetTranscodingJob(string playSessionId) + { + lock (_activeTranscodingJobs) { - lock (_activeTranscodingJobs) - { - return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - } + return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase)); } + } - /// <summary> - /// Ping transcoding job. - /// </summary> - /// <param name="playSessionId">Play session id.</param> - /// <param name="isUserPaused">Is user paused.</param> - /// <exception cref="ArgumentNullException">Play session id is null.</exception> - public void PingTranscodingJob(string playSessionId, bool? isUserPaused) + /// <summary> + /// Get transcoding job. + /// </summary> + /// <param name="path">Path to the transcoding file.</param> + /// <param name="type">The <see cref="TranscodingJobType"/>.</param> + /// <returns>The transcoding job.</returns> + public TranscodingJobDto? GetTranscodingJob(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) { - ArgumentException.ThrowIfNullOrEmpty(playSessionId); + return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + } + } - _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); + /// <summary> + /// Ping transcoding job. + /// </summary> + /// <param name="playSessionId">Play session id.</param> + /// <param name="isUserPaused">Is user paused.</param> + /// <exception cref="ArgumentNullException">Play session id is null.</exception> + public void PingTranscodingJob(string playSessionId, bool? isUserPaused) + { + ArgumentException.ThrowIfNullOrEmpty(playSessionId); - List<TranscodingJobDto> jobs; + _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); - lock (_activeTranscodingJobs) + List<TranscodingJobDto> jobs; + + lock (_activeTranscodingJobs) + { + // This is really only needed for HLS. + // Progressive streams can stop on their own reliably. + jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + foreach (var job in jobs) + { + if (isUserPaused.HasValue) { - // This is really only needed for HLS. - // Progressive streams can stop on their own reliably. - jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); + _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id); + job.IsUserPaused = isUserPaused.Value; } - foreach (var job in jobs) - { - if (isUserPaused.HasValue) - { - _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id); - job.IsUserPaused = isUserPaused.Value; - } + PingTimer(job, true); + } + } - PingTimer(job, true); - } + private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn) + { + if (job.HasExited) + { + job.StopKillTimer(); + return; } - private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn) + var timerDuration = 10000; + + if (job.Type != TranscodingJobType.Progressive) { - if (job.HasExited) - { - job.StopKillTimer(); - return; - } + timerDuration = 60000; + } - var timerDuration = 10000; + job.PingTimeout = timerDuration; + job.LastPingDate = DateTime.UtcNow; - if (job.Type != TranscodingJobType.Progressive) - { - timerDuration = 60000; - } + // Don't start the timer for playback checkins with progressive streaming + if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn) + { + job.StartKillTimer(OnTranscodeKillTimerStopped); + } + else + { + job.ChangeKillTimerIfStarted(); + } + } - job.PingTimeout = timerDuration; - job.LastPingDate = DateTime.UtcNow; + /// <summary> + /// Called when [transcode kill timer stopped]. + /// </summary> + /// <param name="state">The state.</param> + private async void OnTranscodeKillTimerStopped(object? state) + { + var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state)); + if (!job.HasExited && job.Type != TranscodingJobType.Progressive) + { + var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; - // Don't start the timer for playback checkins with progressive streaming - if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn) + if (timeSinceLastPing < job.PingTimeout) { - job.StartKillTimer(OnTranscodeKillTimerStopped); - } - else - { - job.ChangeKillTimerIfStarted(); + job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout); + return; } } - /// <summary> - /// Called when [transcode kill timer stopped]. - /// </summary> - /// <param name="state">The state.</param> - private async void OnTranscodeKillTimerStopped(object? state) - { - var job = state as TranscodingJobDto ?? throw new ArgumentException($"{nameof(state)} is not of type {nameof(TranscodingJobDto)}", nameof(state)); - if (!job.HasExited && job.Type != TranscodingJobType.Progressive) - { - var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; + _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); - if (timeSinceLastPing < job.PingTimeout) - { - job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout); - return; - } - } + await KillTranscodingJob(job, true, path => true).ConfigureAwait(false); + } + + /// <summary> + /// Kills the single transcoding job. + /// </summary> + /// <param name="deviceId">The device id.</param> + /// <param name="playSessionId">The play session identifier.</param> + /// <param name="deleteFiles">The delete files.</param> + /// <returns>Task.</returns> + public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func<string, bool> deleteFiles) + { + return KillTranscodingJobs( + j => string.IsNullOrWhiteSpace(playSessionId) + ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase) + : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), + deleteFiles); + } - _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); + /// <summary> + /// Kills the transcoding jobs. + /// </summary> + /// <param name="killJob">The kill job.</param> + /// <param name="deleteFiles">The delete files.</param> + /// <returns>Task.</returns> + private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles) + { + var jobs = new List<TranscodingJobDto>(); - await KillTranscodingJob(job, true, path => true).ConfigureAwait(false); + lock (_activeTranscodingJobs) + { + // This is really only needed for HLS. + // Progressive streams can stop on their own reliably. + jobs.AddRange(_activeTranscodingJobs.Where(killJob)); } - /// <summary> - /// Kills the single transcoding job. - /// </summary> - /// <param name="deviceId">The device id.</param> - /// <param name="playSessionId">The play session identifier.</param> - /// <param name="deleteFiles">The delete files.</param> - /// <returns>Task.</returns> - public Task KillTranscodingJobs(string deviceId, string? playSessionId, Func<string, bool> deleteFiles) + if (jobs.Count == 0) { - return KillTranscodingJobs( - j => string.IsNullOrWhiteSpace(playSessionId) - ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase) - : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), - deleteFiles); + return Task.CompletedTask; } - /// <summary> - /// Kills the transcoding jobs. - /// </summary> - /// <param name="killJob">The kill job.</param> - /// <param name="deleteFiles">The delete files.</param> - /// <returns>Task.</returns> - private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles) + IEnumerable<Task> GetKillJobs() { - var jobs = new List<TranscodingJobDto>(); - - lock (_activeTranscodingJobs) + foreach (var job in jobs) { - // This is really only needed for HLS. - // Progressive streams can stop on their own reliably. - jobs.AddRange(_activeTranscodingJobs.Where(killJob)); + yield return KillTranscodingJob(job, false, deleteFiles); } + } - if (jobs.Count == 0) - { - return Task.CompletedTask; - } + return Task.WhenAll(GetKillJobs()); + } - IEnumerable<Task> GetKillJobs() - { - foreach (var job in jobs) - { - yield return KillTranscodingJob(job, false, deleteFiles); - } - } + /// <summary> + /// Kills the transcoding job. + /// </summary> + /// <param name="job">The job.</param> + /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param> + /// <param name="delete">The delete.</param> + private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete) + { + job.DisposeKillTimer(); - return Task.WhenAll(GetKillJobs()); - } + _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); - /// <summary> - /// Kills the transcoding job. - /// </summary> - /// <param name="job">The job.</param> - /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param> - /// <param name="delete">The delete.</param> - private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete) + lock (_activeTranscodingJobs) { - job.DisposeKillTimer(); + _activeTranscodingJobs.Remove(job); - _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); - - lock (_activeTranscodingJobs) + if (job.CancellationTokenSource?.IsCancellationRequested == false) { - _activeTranscodingJobs.Remove(job); - - if (job.CancellationTokenSource?.IsCancellationRequested == false) - { - job.CancellationTokenSource.Cancel(); - } + job.CancellationTokenSource.Cancel(); } + } - lock (_transcodingLocks) - { - _transcodingLocks.Remove(job.Path!); - } + lock (_transcodingLocks) + { + _transcodingLocks.Remove(job.Path!); + } - lock (job.ProcessLock!) - { - #pragma warning disable CA1849 // Can't await in lock block - job.TranscodingThrottler?.Stop().GetAwaiter().GetResult(); + lock (job.ProcessLock!) + { +#pragma warning disable CA1849 // Can't await in lock block + job.TranscodingThrottler?.Stop().GetAwaiter().GetResult(); - var process = job.Process; + var process = job.Process; - var hasExited = job.HasExited; + var hasExited = job.HasExited; - if (!hasExited) + if (!hasExited) + { + try { - try - { - _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path); + _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path); - process!.StandardInput.WriteLine("q"); + process!.StandardInput.WriteLine("q"); - // Need to wait because killing is asynchronous. - if (!process.WaitForExit(5000)) - { - _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path); - process.Kill(); - } - } - catch (InvalidOperationException) + // Need to wait because killing is asynchronous. + if (!process.WaitForExit(5000)) { + _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path); + process.Kill(); } } - #pragma warning restore CA1849 + catch (InvalidOperationException) + { + } } +#pragma warning restore CA1849 + } + + if (delete(job.Path!)) + { + await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false); + } - if (delete(job.Path!)) + if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) + { + try { - await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false); + await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); } - - if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) + catch (Exception ex) { - try - { - await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error closing live stream for {Path}", job.Path); - } + _logger.LogError(ex, "Error closing live stream for {Path}", job.Path); } } + } - private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs) + private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs) + { + if (retryCount >= 10) { - if (retryCount >= 10) - { - return; - } + return; + } - _logger.LogInformation("Deleting partial stream file(s) {Path}", path); + _logger.LogInformation("Deleting partial stream file(s) {Path}", path); - await Task.Delay(delayMs).ConfigureAwait(false); + await Task.Delay(delayMs).ConfigureAwait(false); - try - { - if (jobType == TranscodingJobType.Progressive) - { - DeleteProgressivePartialStreamFiles(path); - } - else - { - DeleteHlsPartialStreamFiles(path); - } - } - catch (IOException ex) + try + { + if (jobType == TranscodingJobType.Progressive) { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - - await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false); + DeleteProgressivePartialStreamFiles(path); } - catch (Exception ex) + else { - _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + DeleteHlsPartialStreamFiles(path); } } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - /// <summary> - /// Deletes the progressive partial stream files. - /// </summary> - /// <param name="outputFilePath">The output file path.</param> - private void DeleteProgressivePartialStreamFiles(string outputFilePath) + await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false); + } + catch (Exception ex) { - if (File.Exists(outputFilePath)) - { - _fileSystem.DeleteFile(outputFilePath); - } + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); } + } - /// <summary> - /// Deletes the HLS partial stream files. - /// </summary> - /// <param name="outputFilePath">The output file path.</param> - private void DeleteHlsPartialStreamFiles(string outputFilePath) + /// <summary> + /// Deletes the progressive partial stream files. + /// </summary> + /// <param name="outputFilePath">The output file path.</param> + private void DeleteProgressivePartialStreamFiles(string outputFilePath) + { + if (File.Exists(outputFilePath)) { - var directory = Path.GetDirectoryName(outputFilePath) - ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); + _fileSystem.DeleteFile(outputFilePath); + } + } - var name = Path.GetFileNameWithoutExtension(outputFilePath); + /// <summary> + /// Deletes the HLS partial stream files. + /// </summary> + /// <param name="outputFilePath">The output file path.</param> + private void DeleteHlsPartialStreamFiles(string outputFilePath) + { + var directory = Path.GetDirectoryName(outputFilePath) + ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputFilePath)); + + var name = Path.GetFileNameWithoutExtension(outputFilePath); - var filesToDelete = _fileSystem.GetFilePaths(directory) - .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1); + var filesToDelete = _fileSystem.GetFilePaths(directory) + .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1); - List<Exception>? exs = null; - foreach (var file in filesToDelete) + List<Exception>? exs = null; + foreach (var file in filesToDelete) + { + try { - try - { - _logger.LogDebug("Deleting HLS file {0}", file); - _fileSystem.DeleteFile(file); - } - catch (IOException ex) - { - (exs ??= new List<Exception>(4)).Add(ex); - _logger.LogError(ex, "Error deleting HLS file {Path}", file); - } + _logger.LogDebug("Deleting HLS file {0}", file); + _fileSystem.DeleteFile(file); } - - if (exs is not null) + catch (IOException ex) { - throw new AggregateException("Error deleting HLS files", exs); + (exs ??= new List<Exception>(4)).Add(ex); + _logger.LogError(ex, "Error deleting HLS file {Path}", file); } } - /// <summary> - /// Report the transcoding progress to the session manager. - /// </summary> - /// <param name="job">The <see cref="TranscodingJobDto"/> of which the progress will be reported.</param> - /// <param name="state">The <see cref="StreamState"/> of the current transcoding job.</param> - /// <param name="transcodingPosition">The current transcoding position.</param> - /// <param name="framerate">The framerate of the transcoding job.</param> - /// <param name="percentComplete">The completion percentage of the transcode.</param> - /// <param name="bytesTranscoded">The number of bytes transcoded.</param> - /// <param name="bitRate">The bitrate of the transcoding job.</param> - public void ReportTranscodingProgress( - TranscodingJobDto job, - StreamState state, - TimeSpan? transcodingPosition, - float? framerate, - double? percentComplete, - long? bytesTranscoded, - int? bitRate) - { - var ticks = transcodingPosition?.Ticks; + if (exs is not null) + { + throw new AggregateException("Error deleting HLS files", exs); + } + } - if (job is not null) - { - job.Framerate = framerate; - job.CompletionPercentage = percentComplete; - job.TranscodingPositionTicks = ticks; - job.BytesTranscoded = bytesTranscoded; - job.BitRate = bitRate; - } + /// <summary> + /// Report the transcoding progress to the session manager. + /// </summary> + /// <param name="job">The <see cref="TranscodingJobDto"/> of which the progress will be reported.</param> + /// <param name="state">The <see cref="StreamState"/> of the current transcoding job.</param> + /// <param name="transcodingPosition">The current transcoding position.</param> + /// <param name="framerate">The framerate of the transcoding job.</param> + /// <param name="percentComplete">The completion percentage of the transcode.</param> + /// <param name="bytesTranscoded">The number of bytes transcoded.</param> + /// <param name="bitRate">The bitrate of the transcoding job.</param> + public void ReportTranscodingProgress( + TranscodingJobDto job, + StreamState state, + TimeSpan? transcodingPosition, + float? framerate, + double? percentComplete, + long? bytesTranscoded, + int? bitRate) + { + var ticks = transcodingPosition?.Ticks; - var deviceId = state.Request.DeviceId; + if (job is not null) + { + job.Framerate = framerate; + job.CompletionPercentage = percentComplete; + job.TranscodingPositionTicks = ticks; + job.BytesTranscoded = bytesTranscoded; + job.BitRate = bitRate; + } - if (!string.IsNullOrWhiteSpace(deviceId)) - { - var audioCodec = state.ActualOutputAudioCodec; - var videoCodec = state.ActualOutputVideoCodec; - var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType; - HardwareEncodingType? hardwareAccelerationType = null; - if (!string.IsNullOrEmpty(hardwareAccelerationTypeString) - && Enum.TryParse<HardwareEncodingType>(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType)) - { - hardwareAccelerationType = parsedHardwareAccelerationType; - } + var deviceId = state.Request.DeviceId; - _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo - { - Bitrate = bitRate ?? state.TotalOutputBitrate, - AudioCodec = audioCodec, - VideoCodec = videoCodec, - Container = state.OutputContainer, - Framerate = framerate, - CompletionPercentage = percentComplete, - Width = state.OutputWidth, - Height = state.OutputHeight, - AudioChannels = state.OutputAudioChannels, - IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), - IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), - HardwareAccelerationType = hardwareAccelerationType, - TranscodeReasons = state.TranscodeReasons - }); - } + if (!string.IsNullOrWhiteSpace(deviceId)) + { + var audioCodec = state.ActualOutputAudioCodec; + var videoCodec = state.ActualOutputVideoCodec; + var hardwareAccelerationTypeString = _serverConfigurationManager.GetEncodingOptions().HardwareAccelerationType; + HardwareEncodingType? hardwareAccelerationType = null; + if (!string.IsNullOrEmpty(hardwareAccelerationTypeString) + && Enum.TryParse<HardwareEncodingType>(hardwareAccelerationTypeString, out var parsedHardwareAccelerationType)) + { + hardwareAccelerationType = parsedHardwareAccelerationType; + } + + _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo + { + Bitrate = bitRate ?? state.TotalOutputBitrate, + AudioCodec = audioCodec, + VideoCodec = videoCodec, + Container = state.OutputContainer, + Framerate = framerate, + CompletionPercentage = percentComplete, + Width = state.OutputWidth, + Height = state.OutputHeight, + AudioChannels = state.OutputAudioChannels, + IsAudioDirect = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), + IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), + HardwareAccelerationType = hardwareAccelerationType, + TranscodeReasons = state.TranscodeReasons + }); } + } - /// <summary> - /// Starts FFmpeg. - /// </summary> - /// <param name="state">The state.</param> - /// <param name="outputPath">The output path.</param> - /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param> - /// <param name="request">The <see cref="HttpRequest"/>.</param> - /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> - /// <param name="cancellationTokenSource">The cancellation token source.</param> - /// <param name="workingDirectory">The working directory.</param> - /// <returns>Task.</returns> - public async Task<TranscodingJobDto> StartFfMpeg( - StreamState state, - string outputPath, - string commandLineArguments, - HttpRequest request, - TranscodingJobType transcodingJobType, - CancellationTokenSource cancellationTokenSource, - string? workingDirectory = null) - { - var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - Directory.CreateDirectory(directory); - - await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); - - if (state.VideoRequest is not null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) - { - var userId = request.HttpContext.User.GetUserId(); - var user = userId.Equals(default) ? null : _userManager.GetUserById(userId); - if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) - { - this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - - throw new ArgumentException("User does not have access to video transcoding."); - } - } + /// <summary> + /// Starts FFmpeg. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="outputPath">The output path.</param> + /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param> + /// <param name="request">The <see cref="HttpRequest"/>.</param> + /// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + /// <param name="workingDirectory">The working directory.</param> + /// <returns>Task.</returns> + public async Task<TranscodingJobDto> StartFfMpeg( + StreamState state, + string outputPath, + string commandLineArguments, + HttpRequest request, + TranscodingJobType transcodingJobType, + CancellationTokenSource cancellationTokenSource, + string? workingDirectory = null) + { + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); + Directory.CreateDirectory(directory); - ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath); + await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); - // If subtitles get burned in fonts may need to be extracted from the media file - if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) + if (state.VideoRequest is not null && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + var userId = request.HttpContext.User.GetUserId(); + var user = userId.Equals(default) ? null : _userManager.GetUserById(userId); + if (user is not null && !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)) { - var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id); - await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); - - if (state.SubtitleStream.IsExternal && string.Equals(Path.GetExtension(state.SubtitleStream.Path), ".mks", StringComparison.OrdinalIgnoreCase)) - { - string subtitlePath = state.SubtitleStream.Path; - string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)); - string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture); + this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); - } + throw new ArgumentException("User does not have access to video transcoding."); } + } - var process = new Process - { - StartInfo = new ProcessStartInfo - { - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - UseShellExecute = false, - - // Must consume both stdout and stderr or deadlocks may occur - // RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - FileName = _mediaEncoder.EncoderPath, - Arguments = commandLineArguments, - WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, - ErrorDialog = false - }, - EnableRaisingEvents = true - }; + ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath); + + // If subtitles get burned in fonts may need to be extracted from the media file + if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) + { + var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id); + await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); - var transcodingJob = this.OnTranscodeBeginning( - outputPath, - state.Request.PlaySessionId, - state.MediaSource.LiveStreamId, - Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), - transcodingJobType, - process, - state.Request.DeviceId, - state, - cancellationTokenSource); - - _logger.LogInformation("{Filename} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); - - var logFilePrefix = "FFmpeg.Transcode-"; - if (state.VideoRequest is not null - && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + if (state.SubtitleStream.IsExternal && string.Equals(Path.GetExtension(state.SubtitleStream.Path), ".mks", StringComparison.OrdinalIgnoreCase)) { - logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) - ? "FFmpeg.Remux-" - : "FFmpeg.DirectStream-"; + string subtitlePath = state.SubtitleStream.Path; + string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)); + string subtitleId = subtitlePath.GetMD5().ToString("N", CultureInfo.InvariantCulture); + + await _attachmentExtractor.ExtractAllAttachmentsExternal(subtitlePathArgument, subtitleId, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); } + } - var logFilePath = Path.Combine( - _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, - $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); + var process = new Process + { + StartInfo = new ProcessStartInfo + { + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + UseShellExecute = false, + + // Must consume both stdout and stderr or deadlocks may occur + // RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + FileName = _mediaEncoder.EncoderPath, + Arguments = commandLineArguments, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, + ErrorDialog = false + }, + EnableRaisingEvents = true + }; + + var transcodingJob = this.OnTranscodeBeginning( + outputPath, + state.Request.PlaySessionId, + state.MediaSource.LiveStreamId, + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), + transcodingJobType, + process, + state.Request.DeviceId, + state, + cancellationTokenSource); + + _logger.LogInformation("{Filename} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + + var logFilePrefix = "FFmpeg.Transcode-"; + if (state.VideoRequest is not null + && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + { + logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) + ? "FFmpeg.Remux-" + : "FFmpeg.DirectStream-"; + } - // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. - Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + var logFilePath = Path.Combine( + _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, + $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); - var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; - var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); - await logStream.WriteAsync(commandLineLogMessageBytes, cancellationTokenSource.Token).ConfigureAwait(false); + // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state); + var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); + await logStream.WriteAsync(commandLineLogMessageBytes, cancellationTokenSource.Token).ConfigureAwait(false); - try - { - process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting FFmpeg"); + process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state); - this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); + try + { + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting FFmpeg"); - throw; - } + this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - _logger.LogDebug("Launched FFmpeg process"); - state.TranscodingJob = transcodingJob; + throw; + } - // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback - _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream); + _logger.LogDebug("Launched FFmpeg process"); + state.TranscodingJob = transcodingJob; - // Wait for the file to exist before proceeding - var ffmpegTargetFile = state.WaitForPath ?? outputPath; - _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); - while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) - { - await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false); - } + // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback + _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream); - _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile); + // Wait for the file to exist before proceeding + var ffmpegTargetFile = state.WaitForPath ?? outputPath; + _logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile); + while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited) + { + await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false); + } - if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited) - { - await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false); + _logger.LogDebug("File {0} created or transcoding has finished", ffmpegTargetFile); - if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited) - { - await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false); - } - } + if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited) + { + await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false); - if (!transcodingJob.HasExited) - { - StartThrottler(state, transcodingJob); - } - else if (transcodingJob.ExitCode != 0) + if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited) { - throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "FFmpeg exited with code {0}", transcodingJob.ExitCode)); + await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false); } + } - _logger.LogDebug("StartFfMpeg() finished successfully"); - - return transcodingJob; + if (!transcodingJob.HasExited) + { + StartThrottler(state, transcodingJob); + } + else if (transcodingJob.ExitCode != 0) + { + throw new FfmpegException(string.Format(CultureInfo.InvariantCulture, "FFmpeg exited with code {0}", transcodingJob.ExitCode)); } - private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob) + _logger.LogDebug("StartFfMpeg() finished successfully"); + + return transcodingJob; + } + + private void StartThrottler(StreamState state, TranscodingJobDto transcodingJob) + { + if (EnableThrottling(state)) { - if (EnableThrottling(state)) - { - transcodingJob.TranscodingThrottler = new TranscodingThrottler(transcodingJob, new Logger<TranscodingThrottler>(new LoggerFactory()), _serverConfigurationManager, _fileSystem, _mediaEncoder); - transcodingJob.TranscodingThrottler.Start(); - } + transcodingJob.TranscodingThrottler = new TranscodingThrottler(transcodingJob, new Logger<TranscodingThrottler>(new LoggerFactory()), _serverConfigurationManager, _fileSystem, _mediaEncoder); + transcodingJob.TranscodingThrottler.Start(); } + } + + private bool EnableThrottling(StreamState state) + { + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + + return state.InputProtocol == MediaProtocol.File && + state.RunTimeTicks.HasValue && + state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks && + state.IsInputVideo && + state.VideoType == VideoType.VideoFile; + } - private bool EnableThrottling(StreamState state) + /// <summary> + /// Called when [transcode beginning]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="playSessionId">The play session identifier.</param> + /// <param name="liveStreamId">The live stream identifier.</param> + /// <param name="transcodingJobId">The transcoding job identifier.</param> + /// <param name="type">The type.</param> + /// <param name="process">The process.</param> + /// <param name="deviceId">The device id.</param> + /// <param name="state">The state.</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + /// <returns>TranscodingJob.</returns> + public TranscodingJobDto OnTranscodeBeginning( + string path, + string? playSessionId, + string? liveStreamId, + string transcodingJobId, + TranscodingJobType type, + Process process, + string? deviceId, + StreamState state, + CancellationTokenSource cancellationTokenSource) + { + lock (_activeTranscodingJobs) { - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + var job = new TranscodingJobDto(_loggerFactory.CreateLogger<TranscodingJobDto>()) + { + Type = type, + Path = path, + Process = process, + ActiveRequestCount = 1, + DeviceId = deviceId, + CancellationTokenSource = cancellationTokenSource, + Id = transcodingJobId, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + MediaSource = state.MediaSource + }; - return state.InputProtocol == MediaProtocol.File && - state.RunTimeTicks.HasValue && - state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks && - state.IsInputVideo && - state.VideoType == VideoType.VideoFile; - } - - /// <summary> - /// Called when [transcode beginning]. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="playSessionId">The play session identifier.</param> - /// <param name="liveStreamId">The live stream identifier.</param> - /// <param name="transcodingJobId">The transcoding job identifier.</param> - /// <param name="type">The type.</param> - /// <param name="process">The process.</param> - /// <param name="deviceId">The device id.</param> - /// <param name="state">The state.</param> - /// <param name="cancellationTokenSource">The cancellation token source.</param> - /// <returns>TranscodingJob.</returns> - public TranscodingJobDto OnTranscodeBeginning( - string path, - string? playSessionId, - string? liveStreamId, - string transcodingJobId, - TranscodingJobType type, - Process process, - string? deviceId, - StreamState state, - CancellationTokenSource cancellationTokenSource) - { - lock (_activeTranscodingJobs) - { - var job = new TranscodingJobDto(_loggerFactory.CreateLogger<TranscodingJobDto>()) - { - Type = type, - Path = path, - Process = process, - ActiveRequestCount = 1, - DeviceId = deviceId, - CancellationTokenSource = cancellationTokenSource, - Id = transcodingJobId, - PlaySessionId = playSessionId, - LiveStreamId = liveStreamId, - MediaSource = state.MediaSource - }; - - _activeTranscodingJobs.Add(job); - - ReportTranscodingProgress(job, state, null, null, null, null, null); - - return job; - } + _activeTranscodingJobs.Add(job); + + ReportTranscodingProgress(job, state, null, null, null, null, null); + + return job; } + } - /// <summary> - /// Called when [transcode end]. - /// </summary> - /// <param name="job">The transcode job.</param> - public void OnTranscodeEndRequest(TranscodingJobDto job) + /// <summary> + /// Called when [transcode end]. + /// </summary> + /// <param name="job">The transcode job.</param> + public void OnTranscodeEndRequest(TranscodingJobDto job) + { + job.ActiveRequestCount--; + _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount); + if (job.ActiveRequestCount <= 0) { - job.ActiveRequestCount--; - _logger.LogDebug("OnTranscodeEndRequest job.ActiveRequestCount={ActiveRequestCount}", job.ActiveRequestCount); - if (job.ActiveRequestCount <= 0) - { - PingTimer(job, false); - } + PingTimer(job, false); } + } - /// <summary> - /// <summary> - /// The progressive - /// </summary> - /// Called when [transcode failed to start]. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="type">The type.</param> - /// <param name="state">The state.</param> - public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state) + /// <summary> + /// <summary> + /// The progressive + /// </summary> + /// Called when [transcode failed to start]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="type">The type.</param> + /// <param name="state">The state.</param> + public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state) + { + lock (_activeTranscodingJobs) { - lock (_activeTranscodingJobs) - { - var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - if (job is not null) - { - _activeTranscodingJobs.Remove(job); - } - } - - lock (_transcodingLocks) + if (job is not null) { - _transcodingLocks.Remove(path); + _activeTranscodingJobs.Remove(job); } + } - if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) - { - _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); - } + lock (_transcodingLocks) + { + _transcodingLocks.Remove(path); } - /// <summary> - /// Processes the exited. - /// </summary> - /// <param name="process">The process.</param> - /// <param name="job">The job.</param> - /// <param name="state">The state.</param> - private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state) + if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) { - job.HasExited = true; - job.ExitCode = process.ExitCode; + _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); + } + } - ReportTranscodingProgress(job, state, null, null, null, null, null); + /// <summary> + /// Processes the exited. + /// </summary> + /// <param name="process">The process.</param> + /// <param name="job">The job.</param> + /// <param name="state">The state.</param> + private void OnFfMpegProcessExited(Process process, TranscodingJobDto job, StreamState state) + { + job.HasExited = true; + job.ExitCode = process.ExitCode; - _logger.LogDebug("Disposing stream resources"); - state.Dispose(); + ReportTranscodingProgress(job, state, null, null, null, null, null); - if (process.ExitCode == 0) - { - _logger.LogInformation("FFmpeg exited with code 0"); - } - else - { - _logger.LogError("FFmpeg exited with code {0}", process.ExitCode); - } + _logger.LogDebug("Disposing stream resources"); + state.Dispose(); - job.Dispose(); + if (process.ExitCode == 0) + { + _logger.LogInformation("FFmpeg exited with code 0"); } - - private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) + else { - if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) - { - var liveStreamResponse = await _mediaSourceManager.OpenLiveStream( - new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, - cancellationTokenSource.Token) - .ConfigureAwait(false); - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + _logger.LogError("FFmpeg exited with code {0}", process.ExitCode); + } - _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl); + job.Dispose(); + } - if (state.VideoRequest is not null) - { - _encodingHelper.TryStreamCopy(state); - } - } + private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) + { + if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) + { + var liveStreamResponse = await _mediaSourceManager.OpenLiveStream( + new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken }, + cancellationTokenSource.Token) + .ConfigureAwait(false); + var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); + + _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl); - if (state.MediaSource.BufferMs.HasValue) + if (state.VideoRequest is not null) { - await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false); + _encodingHelper.TryStreamCopy(state); } } - /// <summary> - /// Called when [transcode begin request]. - /// </summary> - /// <param name="path">The path.</param> - /// <param name="type">The type.</param> - /// <returns>The <see cref="TranscodingJobDto"/>.</returns> - public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type) + if (state.MediaSource.BufferMs.HasValue) { - lock (_activeTranscodingJobs) - { - var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - - if (job is null) - { - return null; - } - - OnTranscodeBeginRequest(job); - - return job; - } + await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false); } + } - private void OnTranscodeBeginRequest(TranscodingJobDto job) + /// <summary> + /// Called when [transcode begin request]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="type">The type.</param> + /// <returns>The <see cref="TranscodingJobDto"/>.</returns> + public TranscodingJobDto? OnTranscodeBeginRequest(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) { - job.ActiveRequestCount++; + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); - if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) + if (job is null) { - job.StopKillTimer(); + return null; } + + OnTranscodeBeginRequest(job); + + return job; } + } - /// <summary> - /// Gets the transcoding lock. - /// </summary> - /// <param name="outputPath">The output path of the transcoded file.</param> - /// <returns>A <see cref="SemaphoreSlim"/>.</returns> - public SemaphoreSlim GetTranscodingLock(string outputPath) - { - lock (_transcodingLocks) - { - if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) - { - result = new SemaphoreSlim(1, 1); - _transcodingLocks[outputPath] = result; - } + private void OnTranscodeBeginRequest(TranscodingJobDto job) + { + job.ActiveRequestCount++; - return result; - } + if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) + { + job.StopKillTimer(); } + } - private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) + /// <summary> + /// Gets the transcoding lock. + /// </summary> + /// <param name="outputPath">The output path of the transcoded file.</param> + /// <returns>A <see cref="SemaphoreSlim"/>.</returns> + public SemaphoreSlim GetTranscodingLock(string outputPath) + { + lock (_transcodingLocks) { - if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) + if (!_transcodingLocks.TryGetValue(outputPath, out SemaphoreSlim? result)) { - PingTranscodingJob(e.PlaySessionId, e.IsPaused); + result = new SemaphoreSlim(1, 1); + _transcodingLocks[outputPath] = result; } + + return result; } + } - /// <summary> - /// Deletes the encoded media cache. - /// </summary> - private void DeleteEncodedMediaCache() + private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e) + { + if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) { - var path = _serverConfigurationManager.GetTranscodePath(); - if (!Directory.Exists(path)) - { - return; - } + PingTranscodingJob(e.PlaySessionId, e.IsPaused); + } + } - foreach (var file in _fileSystem.GetFilePaths(path, true)) - { - _fileSystem.DeleteFile(file); - } + /// <summary> + /// Deletes the encoded media cache. + /// </summary> + private void DeleteEncodedMediaCache() + { + var path = _serverConfigurationManager.GetTranscodePath(); + if (!Directory.Exists(path)) + { + return; } - /// <summary> - /// Dispose transcoding job helper. - /// </summary> - public void Dispose() + foreach (var file in _fileSystem.GetFilePaths(path, true)) { - Dispose(true); - GC.SuppressFinalize(this); + _fileSystem.DeleteFile(file); } + } - /// <summary> - /// Dispose throttler. - /// </summary> - /// <param name="disposing">Disposing.</param> - protected virtual void Dispose(bool disposing) + /// <summary> + /// Dispose transcoding job helper. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose throttler. + /// </summary> + /// <param name="disposing">Disposing.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) { - if (disposing) - { - _loggerFactory.Dispose(); - _sessionManager.PlaybackProgress -= OnPlaybackProgress; - _sessionManager.PlaybackStart -= OnPlaybackProgress; - } + _loggerFactory.Dispose(); + _sessionManager.PlaybackProgress -= OnPlaybackProgress; + _sessionManager.PlaybackStart -= OnPlaybackProgress; } } } diff --git a/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs b/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs index 6bd9e0b08..7bcc328aa 100644 --- a/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs +++ b/Jellyfin.Api/Middleware/BaseUrlRedirectionMiddleware.cs @@ -7,75 +7,74 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Redirect requests without baseurl prefix to the baseurl prefixed URL. +/// </summary> +public class BaseUrlRedirectionMiddleware { + private readonly RequestDelegate _next; + private readonly ILogger<BaseUrlRedirectionMiddleware> _logger; + private readonly IConfiguration _configuration; + /// <summary> - /// Redirect requests without baseurl prefix to the baseurl prefixed URL. + /// Initializes a new instance of the <see cref="BaseUrlRedirectionMiddleware"/> class. /// </summary> - public class BaseUrlRedirectionMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + /// <param name="logger">The logger.</param> + /// <param name="configuration">The application configuration.</param> + public BaseUrlRedirectionMiddleware( + RequestDelegate next, + ILogger<BaseUrlRedirectionMiddleware> logger, + IConfiguration configuration) { - private readonly RequestDelegate _next; - private readonly ILogger<BaseUrlRedirectionMiddleware> _logger; - private readonly IConfiguration _configuration; + _next = next; + _logger = logger; + _configuration = configuration; + } - /// <summary> - /// Initializes a new instance of the <see cref="BaseUrlRedirectionMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - /// <param name="logger">The logger.</param> - /// <param name="configuration">The application configuration.</param> - public BaseUrlRedirectionMiddleware( - RequestDelegate next, - ILogger<BaseUrlRedirectionMiddleware> logger, - IConfiguration configuration) - { - _next = next; - _logger = logger; - _configuration = configuration; - } + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager) + { + var localPath = httpContext.Request.Path.ToString(); + var baseUrlPrefix = serverConfigurationManager.GetNetworkConfiguration().BaseUrl; - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="serverConfigurationManager">The server configuration manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager) + if (string.IsNullOrEmpty(localPath) + || string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase) + || string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase) + || string.Equals(localPath, baseUrlPrefix + "/web", StringComparison.OrdinalIgnoreCase) + || string.Equals(localPath, baseUrlPrefix + "/web/", StringComparison.OrdinalIgnoreCase) + || !localPath.StartsWith(baseUrlPrefix, StringComparison.OrdinalIgnoreCase) + ) { - var localPath = httpContext.Request.Path.ToString(); - var baseUrlPrefix = serverConfigurationManager.GetNetworkConfiguration().BaseUrl; - - if (string.IsNullOrEmpty(localPath) - || string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, baseUrlPrefix + "/web", StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, baseUrlPrefix + "/web/", StringComparison.OrdinalIgnoreCase) - || !localPath.StartsWith(baseUrlPrefix, StringComparison.OrdinalIgnoreCase) - ) + // Redirect health endpoint + if (string.Equals(localPath, "/health", StringComparison.OrdinalIgnoreCase) + || string.Equals(localPath, "/health/", StringComparison.OrdinalIgnoreCase)) { - // Redirect health endpoint - if (string.Equals(localPath, "/health", StringComparison.OrdinalIgnoreCase) - || string.Equals(localPath, "/health/", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Redirecting /health check"); - httpContext.Response.Redirect(baseUrlPrefix + "/health"); - return; - } - - // Always redirect back to the default path if the base prefix is invalid or missing - _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath); - - var port = httpContext.Request.Host.Port ?? -1; - var uri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, localPath).Uri; - var redirectUri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, baseUrlPrefix + "/" + _configuration[DefaultRedirectKey]).Uri; - var target = uri.MakeRelativeUri(redirectUri).ToString(); - _logger.LogDebug("Redirecting to {Target}", target); - - httpContext.Response.Redirect(target); + _logger.LogDebug("Redirecting /health check"); + httpContext.Response.Redirect(baseUrlPrefix + "/health"); return; } - await _next(httpContext).ConfigureAwait(false); + // Always redirect back to the default path if the base prefix is invalid or missing + _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath); + + var port = httpContext.Request.Host.Port ?? -1; + var uri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, localPath).Uri; + var redirectUri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, baseUrlPrefix + "/" + _configuration[DefaultRedirectKey]).Uri; + var target = uri.MakeRelativeUri(redirectUri).ToString(); + _logger.LogDebug("Redirecting to {Target}", target); + + httpContext.Response.Redirect(target); + return; } + + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/ExceptionMiddleware.cs b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs index 6b3aeb187..060c14f89 100644 --- a/Jellyfin.Api/Middleware/ExceptionMiddleware.cs +++ b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs @@ -12,140 +12,139 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Exception Middleware. +/// </summary> +public class ExceptionMiddleware { + private readonly RequestDelegate _next; + private readonly ILogger<ExceptionMiddleware> _logger; + private readonly IServerConfigurationManager _configuration; + private readonly IWebHostEnvironment _hostEnvironment; + /// <summary> - /// Exception Middleware. + /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class. /// </summary> - public class ExceptionMiddleware + /// <param name="next">Next request delegate.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="hostEnvironment">Instance of the <see cref="IWebHostEnvironment"/> interface.</param> + public ExceptionMiddleware( + RequestDelegate next, + ILogger<ExceptionMiddleware> logger, + IServerConfigurationManager serverConfigurationManager, + IWebHostEnvironment hostEnvironment) { - private readonly RequestDelegate _next; - private readonly ILogger<ExceptionMiddleware> _logger; - private readonly IServerConfigurationManager _configuration; - private readonly IWebHostEnvironment _hostEnvironment; + _next = next; + _logger = logger; + _configuration = serverConfigurationManager; + _hostEnvironment = hostEnvironment; + } - /// <summary> - /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class. - /// </summary> - /// <param name="next">Next request delegate.</param> - /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <param name="hostEnvironment">Instance of the <see cref="IWebHostEnvironment"/> interface.</param> - public ExceptionMiddleware( - RequestDelegate next, - ILogger<ExceptionMiddleware> logger, - IServerConfigurationManager serverConfigurationManager, - IWebHostEnvironment hostEnvironment) + /// <summary> + /// Invoke request. + /// </summary> + /// <param name="context">Request context.</param> + /// <returns>Task.</returns> + public async Task Invoke(HttpContext context) + { + try { - _next = next; - _logger = logger; - _configuration = serverConfigurationManager; - _hostEnvironment = hostEnvironment; + await _next(context).ConfigureAwait(false); } - - /// <summary> - /// Invoke request. - /// </summary> - /// <param name="context">Request context.</param> - /// <returns>Task.</returns> - public async Task Invoke(HttpContext context) + catch (Exception ex) { - try + if (context.Response.HasStarted) { - await _next(context).ConfigureAwait(false); + _logger.LogWarning("The response has already started, the exception middleware will not be executed."); + throw; } - catch (Exception ex) - { - if (context.Response.HasStarted) - { - _logger.LogWarning("The response has already started, the exception middleware will not be executed."); - throw; - } - ex = GetActualException(ex); + ex = GetActualException(ex); - bool ignoreStackTrace = - ex is SocketException - || ex is IOException - || ex is OperationCanceledException - || ex is SecurityException - || ex is AuthenticationException - || ex is FileNotFoundException; + bool ignoreStackTrace = + ex is SocketException + || ex is IOException + || ex is OperationCanceledException + || ex is SecurityException + || ex is AuthenticationException + || ex is FileNotFoundException; - if (ignoreStackTrace) - { - _logger.LogError( - "Error processing request: {ExceptionMessage}. URL {Method} {Url}.", - ex.Message.TrimEnd('.'), - context.Request.Method, - context.Request.Path); - } - else - { - _logger.LogError( - ex, - "Error processing request. URL {Method} {Url}.", - context.Request.Method, - context.Request.Path); - } + if (ignoreStackTrace) + { + _logger.LogError( + "Error processing request: {ExceptionMessage}. URL {Method} {Url}.", + ex.Message.TrimEnd('.'), + context.Request.Method, + context.Request.Path); + } + else + { + _logger.LogError( + ex, + "Error processing request. URL {Method} {Url}.", + context.Request.Method, + context.Request.Path); + } - context.Response.StatusCode = GetStatusCode(ex); - context.Response.ContentType = MediaTypeNames.Text.Plain; + context.Response.StatusCode = GetStatusCode(ex); + context.Response.ContentType = MediaTypeNames.Text.Plain; - // Don't send exception unless the server is in a Development environment - var errorContent = _hostEnvironment.IsDevelopment() - ? NormalizeExceptionMessage(ex.Message) - : "Error processing request."; - await context.Response.WriteAsync(errorContent).ConfigureAwait(false); - } + // Don't send exception unless the server is in a Development environment + var errorContent = _hostEnvironment.IsDevelopment() + ? NormalizeExceptionMessage(ex.Message) + : "Error processing request."; + await context.Response.WriteAsync(errorContent).ConfigureAwait(false); } + } - private static Exception GetActualException(Exception ex) + private static Exception GetActualException(Exception ex) + { + if (ex is AggregateException agg) { - if (ex is AggregateException agg) + var inner = agg.InnerException; + if (inner is not null) { - var inner = agg.InnerException; - if (inner is not null) - { - return GetActualException(inner); - } - - var inners = agg.InnerExceptions; - if (inners.Count > 0) - { - return GetActualException(inners[0]); - } + return GetActualException(inner); } - return ex; - } - - private static int GetStatusCode(Exception ex) - { - switch (ex) + var inners = agg.InnerExceptions; + if (inners.Count > 0) { - case ArgumentException _: return StatusCodes.Status400BadRequest; - case AuthenticationException _: return StatusCodes.Status401Unauthorized; - case SecurityException _: return StatusCodes.Status403Forbidden; - case DirectoryNotFoundException _: - case FileNotFoundException _: - case ResourceNotFoundException _: return StatusCodes.Status404NotFound; - case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed; - default: return StatusCodes.Status500InternalServerError; + return GetActualException(inners[0]); } } - private string NormalizeExceptionMessage(string msg) + return ex; + } + + private static int GetStatusCode(Exception ex) + { + switch (ex) { - // Strip any information we don't want to reveal - return msg.Replace( - _configuration.ApplicationPaths.ProgramSystemPath, - string.Empty, - StringComparison.OrdinalIgnoreCase) - .Replace( - _configuration.ApplicationPaths.ProgramDataPath, - string.Empty, - StringComparison.OrdinalIgnoreCase); + case ArgumentException _: return StatusCodes.Status400BadRequest; + case AuthenticationException _: return StatusCodes.Status401Unauthorized; + case SecurityException _: return StatusCodes.Status403Forbidden; + case DirectoryNotFoundException _: + case FileNotFoundException _: + case ResourceNotFoundException _: return StatusCodes.Status404NotFound; + case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed; + default: return StatusCodes.Status500InternalServerError; } } + + private string NormalizeExceptionMessage(string msg) + { + // Strip any information we don't want to reveal + return msg.Replace( + _configuration.ApplicationPaths.ProgramSystemPath, + string.Empty, + StringComparison.OrdinalIgnoreCase) + .Replace( + _configuration.ApplicationPaths.ProgramDataPath, + string.Empty, + StringComparison.OrdinalIgnoreCase); + } } diff --git a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs index f7af91e48..f45b6b5c0 100644 --- a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs +++ b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs @@ -4,47 +4,46 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Validates the IP of requests coming from local networks wrt. remote access. +/// </summary> +public class IpBasedAccessValidationMiddleware { + private readonly RequestDelegate _next; + /// <summary> - /// Validates the IP of requests coming from local networks wrt. remote access. + /// Initializes a new instance of the <see cref="IpBasedAccessValidationMiddleware"/> class. /// </summary> - public class IpBasedAccessValidationMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + public IpBasedAccessValidationMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - /// <summary> - /// Initializes a new instance of the <see cref="IpBasedAccessValidationMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public IpBasedAccessValidationMiddleware(RequestDelegate next) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <param name="networkManager">The network manager.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext, INetworkManager networkManager) + { + if (httpContext.IsLocal()) { - _next = next; + // Running locally. + await _next(httpContext).ConfigureAwait(false); + return; } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="networkManager">The network manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext, INetworkManager networkManager) - { - if (httpContext.IsLocal()) - { - // Running locally. - await _next(httpContext).ConfigureAwait(false); - return; - } - - var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - - if (!networkManager.HasRemoteAccess(remoteIp)) - { - return; - } + var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - await _next(httpContext).ConfigureAwait(false); + if (!networkManager.HasRemoteAccess(remoteIp)) + { + return; } + + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs index 18f13bbce..7b05351e3 100644 --- a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs +++ b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs @@ -5,41 +5,40 @@ using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Validates the LAN host IP based on application configuration. +/// </summary> +public class LanFilteringMiddleware { + private readonly RequestDelegate _next; + /// <summary> - /// Validates the LAN host IP based on application configuration. + /// Initializes a new instance of the <see cref="LanFilteringMiddleware"/> class. /// </summary> - public class LanFilteringMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + public LanFilteringMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - /// <summary> - /// Initializes a new instance of the <see cref="LanFilteringMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public LanFilteringMiddleware(RequestDelegate next) - { - _next = next; - } + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <param name="networkManager">The network manager.</param> + /// <param name="serverConfigurationManager">The server configuration manager.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager) + { + var host = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="networkManager">The network manager.</param> - /// <param name="serverConfigurationManager">The server configuration manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager) + if (!networkManager.IsInLocalNetwork(host) && !serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess) { - var host = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - - if (!networkManager.IsInLocalNetwork(host) && !serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess) - { - return; - } - - await _next(httpContext).ConfigureAwait(false); + return; } + + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs b/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs index b73923c1e..17d8997d5 100644 --- a/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs +++ b/Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs @@ -3,52 +3,51 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Removes /emby and /mediabrowser from requested route. +/// </summary> +public class LegacyEmbyRouteRewriteMiddleware { + private const string EmbyPath = "/emby"; + private const string MediabrowserPath = "/mediabrowser"; + + private readonly RequestDelegate _next; + private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger; + /// <summary> - /// Removes /emby and /mediabrowser from requested route. + /// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class. /// </summary> - public class LegacyEmbyRouteRewriteMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + /// <param name="logger">The logger.</param> + public LegacyEmbyRouteRewriteMiddleware( + RequestDelegate next, + ILogger<LegacyEmbyRouteRewriteMiddleware> logger) { - private const string EmbyPath = "/emby"; - private const string MediabrowserPath = "/mediabrowser"; - - private readonly RequestDelegate _next; - private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger; + _next = next; + _logger = logger; + } - /// <summary> - /// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - /// <param name="logger">The logger.</param> - public LegacyEmbyRouteRewriteMiddleware( - RequestDelegate next, - ILogger<LegacyEmbyRouteRewriteMiddleware> logger) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + var localPath = httpContext.Request.Path.ToString(); + if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase)) { - _next = next; - _logger = logger; + httpContext.Request.Path = localPath[EmbyPath.Length..]; + _logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath); } - - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext) + else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase)) { - var localPath = httpContext.Request.Path.ToString(); - if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase)) - { - httpContext.Request.Path = localPath[EmbyPath.Length..]; - _logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath); - } - else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase)) - { - httpContext.Request.Path = localPath[MediabrowserPath.Length..]; - _logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath); - } - - await _next(httpContext).ConfigureAwait(false); + httpContext.Request.Path = localPath[MediabrowserPath.Length..]; + _logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath); } + + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs b/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs index 4b6304e0e..cb4169e99 100644 --- a/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs +++ b/Jellyfin.Api/Middleware/QueryStringDecodingMiddleware.cs @@ -2,38 +2,37 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// URL decodes the querystring before binding. +/// </summary> +public class QueryStringDecodingMiddleware { + private readonly RequestDelegate _next; + /// <summary> - /// URL decodes the querystring before binding. + /// Initializes a new instance of the <see cref="QueryStringDecodingMiddleware"/> class. /// </summary> - public class QueryStringDecodingMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + public QueryStringDecodingMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - /// <summary> - /// Initializes a new instance of the <see cref="QueryStringDecodingMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public QueryStringDecodingMiddleware(RequestDelegate next) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + var feature = httpContext.Features.Get<IQueryFeature>(); + if (feature is not null) { - _next = next; + httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(feature)); } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext) - { - var feature = httpContext.Features.Get<IQueryFeature>(); - if (feature is not null) - { - httpContext.Features.Set<IQueryFeature>(new UrlDecodeQueryFeature(feature)); - } - - await _next(httpContext).ConfigureAwait(false); - } + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs b/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs index 3701d0f45..db3917743 100644 --- a/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs +++ b/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs @@ -7,63 +7,62 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Response time middleware. +/// </summary> +public class ResponseTimeMiddleware { + private const string ResponseHeaderResponseTime = "X-Response-Time-ms"; + + private readonly RequestDelegate _next; + private readonly ILogger<ResponseTimeMiddleware> _logger; + /// <summary> - /// Response time middleware. + /// Initializes a new instance of the <see cref="ResponseTimeMiddleware"/> class. /// </summary> - public class ResponseTimeMiddleware + /// <param name="next">Next request delegate.</param> + /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> + public ResponseTimeMiddleware( + RequestDelegate next, + ILogger<ResponseTimeMiddleware> logger) { - private const string ResponseHeaderResponseTime = "X-Response-Time-ms"; - - private readonly RequestDelegate _next; - private readonly ILogger<ResponseTimeMiddleware> _logger; + _next = next; + _logger = logger; + } - /// <summary> - /// Initializes a new instance of the <see cref="ResponseTimeMiddleware"/> class. - /// </summary> - /// <param name="next">Next request delegate.</param> - /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param> - public ResponseTimeMiddleware( - RequestDelegate next, - ILogger<ResponseTimeMiddleware> logger) - { - _next = next; - _logger = logger; - } + /// <summary> + /// Invoke request. + /// </summary> + /// <param name="context">Request context.</param> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + /// <returns>Task.</returns> + public async Task Invoke(HttpContext context, IServerConfigurationManager serverConfigurationManager) + { + var startTimestamp = Stopwatch.GetTimestamp(); - /// <summary> - /// Invoke request. - /// </summary> - /// <param name="context">Request context.</param> - /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> - /// <returns>Task.</returns> - public async Task Invoke(HttpContext context, IServerConfigurationManager serverConfigurationManager) + var enableWarning = serverConfigurationManager.Configuration.EnableSlowResponseWarning; + var warningThreshold = serverConfigurationManager.Configuration.SlowResponseThresholdMs; + context.Response.OnStarting(() => { - var startTimestamp = Stopwatch.GetTimestamp(); - - var enableWarning = serverConfigurationManager.Configuration.EnableSlowResponseWarning; - var warningThreshold = serverConfigurationManager.Configuration.SlowResponseThresholdMs; - context.Response.OnStarting(() => + var responseTime = Stopwatch.GetElapsedTime(startTimestamp); + var responseTimeMs = responseTime.TotalMilliseconds; + if (enableWarning && responseTimeMs > warningThreshold && _logger.IsEnabled(LogLevel.Debug)) { - var responseTime = Stopwatch.GetElapsedTime(startTimestamp); - var responseTimeMs = responseTime.TotalMilliseconds; - if (enableWarning && responseTimeMs > warningThreshold && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Slow HTTP Response from {Url} to {RemoteIp} in {Elapsed:g} with Status Code {StatusCode}", - context.Request.GetDisplayUrl(), - context.GetNormalizedRemoteIp(), - responseTime, - context.Response.StatusCode); - } + _logger.LogDebug( + "Slow HTTP Response from {Url} to {RemoteIp} in {Elapsed:g} with Status Code {StatusCode}", + context.Request.GetDisplayUrl(), + context.GetNormalizedRemoteIp(), + responseTime, + context.Response.StatusCode); + } - context.Response.Headers[ResponseHeaderResponseTime] = responseTimeMs.ToString(CultureInfo.InvariantCulture); - return Task.CompletedTask; - }); + context.Response.Headers[ResponseHeaderResponseTime] = responseTimeMs.ToString(CultureInfo.InvariantCulture); + return Task.CompletedTask; + }); - // Call the next delegate/middleware in the pipeline - await this._next(context).ConfigureAwait(false); - } + // Call the next delegate/middleware in the pipeline + await this._next(context).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs b/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs index 2e69580be..8bf626035 100644 --- a/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs +++ b/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs @@ -3,45 +3,44 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Redirect requests to robots.txt to web/robots.txt. +/// </summary> +public class RobotsRedirectionMiddleware { + private readonly RequestDelegate _next; + private readonly ILogger<RobotsRedirectionMiddleware> _logger; + /// <summary> - /// Redirect requests to robots.txt to web/robots.txt. + /// Initializes a new instance of the <see cref="RobotsRedirectionMiddleware"/> class. /// </summary> - public class RobotsRedirectionMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + /// <param name="logger">The logger.</param> + public RobotsRedirectionMiddleware( + RequestDelegate next, + ILogger<RobotsRedirectionMiddleware> logger) { - private readonly RequestDelegate _next; - private readonly ILogger<RobotsRedirectionMiddleware> _logger; + _next = next; + _logger = logger; + } - /// <summary> - /// Initializes a new instance of the <see cref="RobotsRedirectionMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - /// <param name="logger">The logger.</param> - public RobotsRedirectionMiddleware( - RequestDelegate next, - ILogger<RobotsRedirectionMiddleware> logger) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext) + { + var localPath = httpContext.Request.Path.ToString(); + if (string.Equals(localPath, "/robots.txt", StringComparison.OrdinalIgnoreCase)) { - _next = next; - _logger = logger; + _logger.LogDebug("Redirecting robots.txt request to web/robots.txt"); + httpContext.Response.Redirect("web/robots.txt"); + return; } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext) - { - var localPath = httpContext.Request.Path.ToString(); - if (string.Equals(localPath, "/robots.txt", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Redirecting robots.txt request to web/robots.txt"); - httpContext.Response.Redirect("web/robots.txt"); - return; - } - - await _next(httpContext).ConfigureAwait(false); - } + await _next(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs b/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs index dcd64401a..dcb234658 100644 --- a/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs +++ b/Jellyfin.Api/Middleware/ServerStartupMessageMiddleware.cs @@ -5,47 +5,46 @@ using MediaBrowser.Controller; using MediaBrowser.Model.Globalization; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Shows a custom message during server startup. +/// </summary> +public class ServerStartupMessageMiddleware { + private readonly RequestDelegate _next; + /// <summary> - /// Shows a custom message during server startup. + /// Initializes a new instance of the <see cref="ServerStartupMessageMiddleware"/> class. /// </summary> - public class ServerStartupMessageMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + public ServerStartupMessageMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - /// <summary> - /// Initializes a new instance of the <see cref="ServerStartupMessageMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public ServerStartupMessageMiddleware(RequestDelegate next) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <param name="serverApplicationHost">The server application host.</param> + /// <param name="localizationManager">The localization manager.</param> + /// <returns>The async task.</returns> + public async Task Invoke( + HttpContext httpContext, + IServerApplicationHost serverApplicationHost, + ILocalizationManager localizationManager) + { + if (serverApplicationHost.CoreStartupHasCompleted + || httpContext.Request.Path.Equals("/system/ping", StringComparison.OrdinalIgnoreCase)) { - _next = next; + await _next(httpContext).ConfigureAwait(false); + return; } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="serverApplicationHost">The server application host.</param> - /// <param name="localizationManager">The localization manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke( - HttpContext httpContext, - IServerApplicationHost serverApplicationHost, - ILocalizationManager localizationManager) - { - if (serverApplicationHost.CoreStartupHasCompleted - || httpContext.Request.Path.Equals("/system/ping", StringComparison.OrdinalIgnoreCase)) - { - await _next(httpContext).ConfigureAwait(false); - return; - } - - var message = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading"); - httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; - httpContext.Response.ContentType = MediaTypeNames.Text.Html; - await httpContext.Response.WriteAsync(message, httpContext.RequestAborted).ConfigureAwait(false); - } + var message = localizationManager.GetLocalizedString("StartupEmbyServerIsLoading"); + httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + httpContext.Response.ContentType = MediaTypeNames.Text.Html; + await httpContext.Response.WriteAsync(message, httpContext.RequestAborted).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs b/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs index d35e0fcfd..f75d0d24e 100644 --- a/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs +++ b/Jellyfin.Api/Middleware/UrlDecodeQueryFeature.cs @@ -6,79 +6,78 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Defines the <see cref="UrlDecodeQueryFeature"/>. +/// </summary> +public class UrlDecodeQueryFeature : IQueryFeature { + private IQueryCollection? _store; + /// <summary> - /// Defines the <see cref="UrlDecodeQueryFeature"/>. + /// Initializes a new instance of the <see cref="UrlDecodeQueryFeature"/> class. /// </summary> - public class UrlDecodeQueryFeature : IQueryFeature + /// <param name="feature">The <see cref="IQueryFeature"/> instance.</param> + public UrlDecodeQueryFeature(IQueryFeature feature) { - private IQueryCollection? _store; + Query = feature.Query; + } - /// <summary> - /// Initializes a new instance of the <see cref="UrlDecodeQueryFeature"/> class. - /// </summary> - /// <param name="feature">The <see cref="IQueryFeature"/> instance.</param> - public UrlDecodeQueryFeature(IQueryFeature feature) + /// <summary> + /// Gets or sets a value indicating the url decoded <see cref="IQueryCollection"/>. + /// </summary> + public IQueryCollection Query + { + get { - Query = feature.Query; + return _store ?? QueryCollection.Empty; } - /// <summary> - /// Gets or sets a value indicating the url decoded <see cref="IQueryCollection"/>. - /// </summary> - public IQueryCollection Query + set { - get + // Only interested in where the querystring is encoded which shows up as one key with nothing in the value. + if (value.Count != 1) { - return _store ?? QueryCollection.Empty; + _store = value; + return; } - set + // Encoded querystrings have no value, so don't process anything if a value is present. + var (key, stringValues) = value.First(); + if (!string.IsNullOrEmpty(stringValues)) { - // Only interested in where the querystring is encoded which shows up as one key with nothing in the value. - if (value.Count != 1) - { - _store = value; - return; - } + _store = value; + return; + } - // Encoded querystrings have no value, so don't process anything if a value is present. - var (key, stringValues) = value.First(); - if (!string.IsNullOrEmpty(stringValues)) - { - _store = value; - return; - } + if (!key.Contains('=', StringComparison.Ordinal)) + { + _store = value; + return; + } - if (!key.Contains('=', StringComparison.Ordinal)) + var pairs = new Dictionary<string, StringValues>(); + foreach (var pair in key.SpanSplit('&')) + { + var i = pair.IndexOf('='); + if (i == -1) { - _store = value; - return; + // encoded is an equals. + // We use TryAdd so duplicate keys get ignored + pairs.TryAdd(pair.ToString(), StringValues.Empty); + continue; } - var pairs = new Dictionary<string, StringValues>(); - foreach (var pair in key.SpanSplit('&')) + var k = pair[..i].ToString(); + var v = pair[(i + 1)..].ToString(); + if (!pairs.TryAdd(k, new StringValues(v))) { - var i = pair.IndexOf('='); - if (i == -1) - { - // encoded is an equals. - // We use TryAdd so duplicate keys get ignored - pairs.TryAdd(pair.ToString(), StringValues.Empty); - continue; - } - - var k = pair[..i].ToString(); - var v = pair[(i + 1)..].ToString(); - if (!pairs.TryAdd(k, new StringValues(v))) - { - pairs[k] = StringValues.Concat(pairs[k], v); - } + pairs[k] = StringValues.Concat(pairs[k], v); } - - _store = new QueryCollection(pairs); } + + _store = new QueryCollection(pairs); } } } diff --git a/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs b/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs index 2cf1e5e4a..009fb6269 100644 --- a/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs +++ b/Jellyfin.Api/Middleware/WebSocketHandlerMiddleware.cs @@ -2,39 +2,38 @@ using System.Threading.Tasks; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; -namespace Jellyfin.Api.Middleware +namespace Jellyfin.Api.Middleware; + +/// <summary> +/// Handles WebSocket requests. +/// </summary> +public class WebSocketHandlerMiddleware { + private readonly RequestDelegate _next; + /// <summary> - /// Handles WebSocket requests. + /// Initializes a new instance of the <see cref="WebSocketHandlerMiddleware"/> class. /// </summary> - public class WebSocketHandlerMiddleware + /// <param name="next">The next delegate in the pipeline.</param> + public WebSocketHandlerMiddleware(RequestDelegate next) { - private readonly RequestDelegate _next; + _next = next; + } - /// <summary> - /// Initializes a new instance of the <see cref="WebSocketHandlerMiddleware"/> class. - /// </summary> - /// <param name="next">The next delegate in the pipeline.</param> - public WebSocketHandlerMiddleware(RequestDelegate next) + /// <summary> + /// Executes the middleware action. + /// </summary> + /// <param name="httpContext">The current HTTP context.</param> + /// <param name="webSocketManager">The WebSocket connection manager.</param> + /// <returns>The async task.</returns> + public async Task Invoke(HttpContext httpContext, IWebSocketManager webSocketManager) + { + if (!httpContext.WebSockets.IsWebSocketRequest) { - _next = next; + await _next(httpContext).ConfigureAwait(false); + return; } - /// <summary> - /// Executes the middleware action. - /// </summary> - /// <param name="httpContext">The current HTTP context.</param> - /// <param name="webSocketManager">The WebSocket connection manager.</param> - /// <returns>The async task.</returns> - public async Task Invoke(HttpContext httpContext, IWebSocketManager webSocketManager) - { - if (!httpContext.WebSockets.IsWebSocketRequest) - { - await _next(httpContext).ConfigureAwait(false); - return; - } - - await webSocketManager.WebSocketRequestHandler(httpContext).ConfigureAwait(false); - } + await webSocketManager.WebSocketRequestHandler(httpContext).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs index 75e47a71b..a34fd01d5 100644 --- a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs @@ -5,86 +5,85 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.ModelBinders +namespace Jellyfin.Api.ModelBinders; + +/// <summary> +/// Comma delimited array model binder. +/// Returns an empty array of specified type if there is no query parameter. +/// </summary> +public class CommaDelimitedArrayModelBinder : IModelBinder { + private readonly ILogger<CommaDelimitedArrayModelBinder> _logger; + /// <summary> - /// Comma delimited array model binder. - /// Returns an empty array of specified type if there is no query parameter. + /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinder"/> class. /// </summary> - public class CommaDelimitedArrayModelBinder : IModelBinder + /// <param name="logger">Instance of the <see cref="ILogger{CommaDelimitedArrayModelBinder}"/> interface.</param> + public CommaDelimitedArrayModelBinder(ILogger<CommaDelimitedArrayModelBinder> logger) + { + _logger = logger; + } + + /// <inheritdoc/> + public Task BindModelAsync(ModelBindingContext bindingContext) { - private readonly ILogger<CommaDelimitedArrayModelBinder> _logger; + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; + var converter = TypeDescriptor.GetConverter(elementType); - /// <summary> - /// Initializes a new instance of the <see cref="CommaDelimitedArrayModelBinder"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{CommaDelimitedArrayModelBinder}"/> interface.</param> - public CommaDelimitedArrayModelBinder(ILogger<CommaDelimitedArrayModelBinder> logger) + if (valueProviderResult.Length > 1) { - _logger = logger; + var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); } - - /// <inheritdoc/> - public Task BindModelAsync(ModelBindingContext bindingContext) + else { - var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; - var converter = TypeDescriptor.GetConverter(elementType); + var value = valueProviderResult.FirstValue; - if (valueProviderResult.Length > 1) + if (value is not null) { - var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + var splitValues = value.Split(',', StringSplitOptions.RemoveEmptyEntries); + var typedValues = GetParsedResult(splitValues, elementType, converter); bindingContext.Result = ModelBindingResult.Success(typedValues); } else { - var value = valueProviderResult.FirstValue; - - if (value is not null) - { - var splitValues = value.Split(',', StringSplitOptions.RemoveEmptyEntries); - var typedValues = GetParsedResult(splitValues, elementType, converter); - bindingContext.Result = ModelBindingResult.Success(typedValues); - } - else - { - var emptyResult = Array.CreateInstance(elementType, 0); - bindingContext.Result = ModelBindingResult.Success(emptyResult); - } + var emptyResult = Array.CreateInstance(elementType, 0); + bindingContext.Result = ModelBindingResult.Success(emptyResult); } - - return Task.CompletedTask; } - private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + return Task.CompletedTask; + } + + private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + { + var parsedValues = new object?[values.Count]; + var convertedCount = 0; + for (var i = 0; i < values.Count; i++) { - var parsedValues = new object?[values.Count]; - var convertedCount = 0; - for (var i = 0; i < values.Count; i++) + try { - try - { - parsedValues[i] = converter.ConvertFromString(values[i].Trim()); - convertedCount++; - } - catch (FormatException e) - { - _logger.LogDebug(e, "Error converting value."); - } + parsedValues[i] = converter.ConvertFromString(values[i].Trim()); + convertedCount++; } - - var typedValues = Array.CreateInstance(elementType, convertedCount); - var typedValueIndex = 0; - for (var i = 0; i < parsedValues.Length; i++) + catch (FormatException e) { - if (parsedValues[i] != null) - { - typedValues.SetValue(parsedValues[i], typedValueIndex); - typedValueIndex++; - } + _logger.LogDebug(e, "Error converting value."); } + } - return typedValues; + var typedValues = Array.CreateInstance(elementType, convertedCount); + var typedValueIndex = 0; + for (var i = 0; i < parsedValues.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } } + + return typedValues; } } diff --git a/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs index e1cb725f3..87a30773e 100644 --- a/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs @@ -5,45 +5,44 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.ModelBinders +namespace Jellyfin.Api.ModelBinders; + +/// <summary> +/// DateTime model binder. +/// </summary> +public class LegacyDateTimeModelBinder : IModelBinder { + // Borrowed from the DateTimeModelBinderProvider + private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces; + private readonly DateTimeModelBinder _defaultModelBinder; + /// <summary> - /// DateTime model binder. + /// Initializes a new instance of the <see cref="LegacyDateTimeModelBinder"/> class. /// </summary> - public class LegacyDateTimeModelBinder : IModelBinder + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory) { - // Borrowed from the DateTimeModelBinderProvider - private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces; - private readonly DateTimeModelBinder _defaultModelBinder; - - /// <summary> - /// Initializes a new instance of the <see cref="LegacyDateTimeModelBinder"/> class. - /// </summary> - /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> - public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory) - { - _defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory); - } + _defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory); + } - /// <inheritdoc /> - public Task BindModelAsync(ModelBindingContext bindingContext) + /// <inheritdoc /> + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + if (valueProviderResult.Values.Count == 1) { - var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - if (valueProviderResult.Values.Count == 1) + var dateTimeString = valueProviderResult.FirstValue; + // Mark Played Item. + if (DateTime.TryParseExact(dateTimeString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime)) { - var dateTimeString = valueProviderResult.FirstValue; - // Mark Played Item. - if (DateTime.TryParseExact(dateTimeString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime)) - { - bindingContext.Result = ModelBindingResult.Success(dateTime); - } - else - { - return _defaultModelBinder.BindModelAsync(bindingContext); - } + bindingContext.Result = ModelBindingResult.Success(dateTime); + } + else + { + return _defaultModelBinder.BindModelAsync(bindingContext); } - - return Task.CompletedTask; } + + return Task.CompletedTask; } } diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs index d2e78ac88..a2e139ca1 100644 --- a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs @@ -4,45 +4,44 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.ModelBinders +namespace Jellyfin.Api.ModelBinders; + +/// <summary> +/// Nullable enum model binder. +/// </summary> +public class NullableEnumModelBinder : IModelBinder { + private readonly ILogger<NullableEnumModelBinder> _logger; + /// <summary> - /// Nullable enum model binder. + /// Initializes a new instance of the <see cref="NullableEnumModelBinder"/> class. /// </summary> - public class NullableEnumModelBinder : IModelBinder + /// <param name="logger">Instance of the <see cref="ILogger{NullableEnumModelBinder}"/> interface.</param> + public NullableEnumModelBinder(ILogger<NullableEnumModelBinder> logger) { - private readonly ILogger<NullableEnumModelBinder> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="NullableEnumModelBinder"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{NullableEnumModelBinder}"/> interface.</param> - public NullableEnumModelBinder(ILogger<NullableEnumModelBinder> logger) - { - _logger = logger; - } + _logger = logger; + } - /// <inheritdoc /> - public Task BindModelAsync(ModelBindingContext bindingContext) + /// <inheritdoc /> + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; + var converter = TypeDescriptor.GetConverter(elementType); + if (valueProviderResult.Length != 0) { - var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; - var converter = TypeDescriptor.GetConverter(elementType); - if (valueProviderResult.Length != 0) + try { - try - { - // REVIEW: This shouldn't be null here - var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue!); - bindingContext.Result = ModelBindingResult.Success(convertedValue); - } - catch (FormatException e) - { - _logger.LogDebug(e, "Error converting value."); - } + // REVIEW: This shouldn't be null here + var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue!); + bindingContext.Result = ModelBindingResult.Success(convertedValue); + } + catch (FormatException e) + { + _logger.LogDebug(e, "Error converting value."); } - - return Task.CompletedTask; } + + return Task.CompletedTask; } } diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs index da0addd0e..43ffdaefd 100644 --- a/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs +++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs @@ -3,25 +3,24 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.ModelBinders +namespace Jellyfin.Api.ModelBinders; + +/// <summary> +/// Nullable enum model binder provider. +/// </summary> +public class NullableEnumModelBinderProvider : IModelBinderProvider { - /// <summary> - /// Nullable enum model binder provider. - /// </summary> - public class NullableEnumModelBinderProvider : IModelBinderProvider + /// <inheritdoc /> + public IModelBinder? GetBinder(ModelBinderProviderContext context) { - /// <inheritdoc /> - public IModelBinder? GetBinder(ModelBinderProviderContext context) + var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType); + if (nullableType is null || !nullableType.IsEnum) { - var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType); - if (nullableType is null || !nullableType.IsEnum) - { - // Type isn't nullable or isn't an enum. - return null; - } - - var logger = context.Services.GetRequiredService<ILogger<NullableEnumModelBinder>>(); - return new NullableEnumModelBinder(logger); + // Type isn't nullable or isn't an enum. + return null; } + + var logger = context.Services.GetRequiredService<ILogger<NullableEnumModelBinder>>(); + return new NullableEnumModelBinder(logger); } } diff --git a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs index 4257ba0e2..cb9a82955 100644 --- a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs @@ -5,86 +5,85 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.ModelBinders +namespace Jellyfin.Api.ModelBinders; + +/// <summary> +/// Comma delimited array model binder. +/// Returns an empty array of specified type if there is no query parameter. +/// </summary> +public class PipeDelimitedArrayModelBinder : IModelBinder { + private readonly ILogger<PipeDelimitedArrayModelBinder> _logger; + /// <summary> - /// Comma delimited array model binder. - /// Returns an empty array of specified type if there is no query parameter. + /// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class. /// </summary> - public class PipeDelimitedArrayModelBinder : IModelBinder + /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param> + public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger) + { + _logger = logger; + } + + /// <inheritdoc/> + public Task BindModelAsync(ModelBindingContext bindingContext) { - private readonly ILogger<PipeDelimitedArrayModelBinder> _logger; + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; + var converter = TypeDescriptor.GetConverter(elementType); - /// <summary> - /// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param> - public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger) + if (valueProviderResult.Length > 1) { - _logger = logger; + var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); } - - /// <inheritdoc/> - public Task BindModelAsync(ModelBindingContext bindingContext) + else { - var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); - var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0]; - var converter = TypeDescriptor.GetConverter(elementType); + var value = valueProviderResult.FirstValue; - if (valueProviderResult.Length > 1) + if (value is not null) { - var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + var splitValues = value.Split('|', StringSplitOptions.RemoveEmptyEntries); + var typedValues = GetParsedResult(splitValues, elementType, converter); bindingContext.Result = ModelBindingResult.Success(typedValues); } else { - var value = valueProviderResult.FirstValue; - - if (value is not null) - { - var splitValues = value.Split('|', StringSplitOptions.RemoveEmptyEntries); - var typedValues = GetParsedResult(splitValues, elementType, converter); - bindingContext.Result = ModelBindingResult.Success(typedValues); - } - else - { - var emptyResult = Array.CreateInstance(elementType, 0); - bindingContext.Result = ModelBindingResult.Success(emptyResult); - } + var emptyResult = Array.CreateInstance(elementType, 0); + bindingContext.Result = ModelBindingResult.Success(emptyResult); } - - return Task.CompletedTask; } - private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + return Task.CompletedTask; + } + + private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter) + { + var parsedValues = new object?[values.Count]; + var convertedCount = 0; + for (var i = 0; i < values.Count; i++) { - var parsedValues = new object?[values.Count]; - var convertedCount = 0; - for (var i = 0; i < values.Count; i++) + try { - try - { - parsedValues[i] = converter.ConvertFromString(values[i].Trim()); - convertedCount++; - } - catch (FormatException e) - { - _logger.LogDebug(e, "Error converting value."); - } + parsedValues[i] = converter.ConvertFromString(values[i].Trim()); + convertedCount++; } - - var typedValues = Array.CreateInstance(elementType, convertedCount); - var typedValueIndex = 0; - for (var i = 0; i < parsedValues.Length; i++) + catch (FormatException e) { - if (parsedValues[i] != null) - { - typedValues.SetValue(parsedValues[i], typedValueIndex); - typedValueIndex++; - } + _logger.LogDebug(e, "Error converting value."); } + } - return typedValues; + var typedValues = Array.CreateInstance(elementType, convertedCount); + var typedValueIndex = 0; + for (var i = 0; i < parsedValues.Length; i++) + { + if (parsedValues[i] != null) + { + typedValues.SetValue(parsedValues[i], typedValueIndex); + typedValueIndex++; + } } + + return typedValues; } } diff --git a/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs b/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs index 44509a9c0..168247fd5 100644 --- a/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs +++ b/Jellyfin.Api/Models/ClientLogDtos/ClientLogDocumentResponseDto.cs @@ -1,22 +1,21 @@ -namespace Jellyfin.Api.Models.ClientLogDtos +namespace Jellyfin.Api.Models.ClientLogDtos; + +/// <summary> +/// Client log document response dto. +/// </summary> +public class ClientLogDocumentResponseDto { /// <summary> - /// Client log document response dto. + /// Initializes a new instance of the <see cref="ClientLogDocumentResponseDto"/> class. /// </summary> - public class ClientLogDocumentResponseDto + /// <param name="fileName">The file name.</param> + public ClientLogDocumentResponseDto(string fileName) { - /// <summary> - /// Initializes a new instance of the <see cref="ClientLogDocumentResponseDto"/> class. - /// </summary> - /// <param name="fileName">The file name.</param> - public ClientLogDocumentResponseDto(string fileName) - { - FileName = fileName; - } - - /// <summary> - /// Gets the resulting filename. - /// </summary> - public string FileName { get; } + FileName = fileName; } + + /// <summary> + /// Gets the resulting filename. + /// </summary> + public string FileName { get; } } diff --git a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs index 3b827ec12..5a48345eb 100644 --- a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs +++ b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.ConfigurationDtos +namespace Jellyfin.Api.Models.ConfigurationDtos; + +/// <summary> +/// Media Encoder Path Dto. +/// </summary> +public class MediaEncoderPathDto { /// <summary> - /// Media Encoder Path Dto. + /// Gets or sets media encoder path. /// </summary> - public class MediaEncoderPathDto - { - /// <summary> - /// Gets or sets media encoder path. - /// </summary> - public string Path { get; set; } = null!; + public string Path { get; set; } = null!; - /// <summary> - /// Gets or sets media encoder path type. - /// </summary> - public string PathType { get; set; } = null!; - } + /// <summary> + /// Gets or sets media encoder path type. + /// </summary> + public string PathType { get; set; } = null!; } diff --git a/Jellyfin.Api/Models/ConfigurationPageInfo.cs b/Jellyfin.Api/Models/ConfigurationPageInfo.cs index ec4a0d1a1..e7bcd6c53 100644 --- a/Jellyfin.Api/Models/ConfigurationPageInfo.cs +++ b/Jellyfin.Api/Models/ConfigurationPageInfo.cs @@ -2,66 +2,65 @@ using System; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; -namespace Jellyfin.Api.Models +namespace Jellyfin.Api.Models; + +/// <summary> +/// The configuration page info. +/// </summary> +public class ConfigurationPageInfo { /// <summary> - /// The configuration page info. + /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. /// </summary> - public class ConfigurationPageInfo + /// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param> + /// <param name="page">Instance of <see cref="PluginPageInfo"/> interface.</param> + public ConfigurationPageInfo(IPlugin? plugin, PluginPageInfo page) { - /// <summary> - /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. - /// </summary> - /// <param name="plugin">Instance of <see cref="IPlugin"/> interface.</param> - /// <param name="page">Instance of <see cref="PluginPageInfo"/> interface.</param> - public ConfigurationPageInfo(IPlugin? plugin, PluginPageInfo page) - { - Name = page.Name; - EnableInMainMenu = page.EnableInMainMenu; - MenuSection = page.MenuSection; - MenuIcon = page.MenuIcon; - DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin?.Name : page.DisplayName; - PluginId = plugin?.Id; - } + Name = page.Name; + EnableInMainMenu = page.EnableInMainMenu; + MenuSection = page.MenuSection; + MenuIcon = page.MenuIcon; + DisplayName = string.IsNullOrWhiteSpace(page.DisplayName) ? plugin?.Name : page.DisplayName; + PluginId = plugin?.Id; + } - /// <summary> - /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. - /// </summary> - public ConfigurationPageInfo() - { - Name = string.Empty; - } + /// <summary> + /// Initializes a new instance of the <see cref="ConfigurationPageInfo"/> class. + /// </summary> + public ConfigurationPageInfo() + { + Name = string.Empty; + } - /// <summary> - /// Gets or sets the name. - /// </summary> - /// <value>The name.</value> - public string Name { get; set; } + /// <summary> + /// Gets or sets the name. + /// </summary> + /// <value>The name.</value> + public string Name { get; set; } - /// <summary> - /// Gets or sets a value indicating whether the configurations page is enabled in the main menu. - /// </summary> - public bool EnableInMainMenu { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the configurations page is enabled in the main menu. + /// </summary> + public bool EnableInMainMenu { get; set; } - /// <summary> - /// Gets or sets the menu section. - /// </summary> - public string? MenuSection { get; set; } + /// <summary> + /// Gets or sets the menu section. + /// </summary> + public string? MenuSection { get; set; } - /// <summary> - /// Gets or sets the menu icon. - /// </summary> - public string? MenuIcon { get; set; } + /// <summary> + /// Gets or sets the menu icon. + /// </summary> + public string? MenuIcon { get; set; } - /// <summary> - /// Gets or sets the display name. - /// </summary> - public string? DisplayName { get; set; } + /// <summary> + /// Gets or sets the display name. + /// </summary> + public string? DisplayName { get; set; } - /// <summary> - /// Gets or sets the plugin id. - /// </summary> - /// <value>The plugin id.</value> - public Guid? PluginId { get; set; } - } + /// <summary> + /// Gets or sets the plugin id. + /// </summary> + /// <value>The plugin id.</value> + public Guid? PluginId { get; set; } } diff --git a/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs b/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs index 92be15b8a..c438e5a97 100644 --- a/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs +++ b/Jellyfin.Api/Models/EnvironmentDtos/DefaultDirectoryBrowserInfoDto.cs @@ -1,13 +1,12 @@ -namespace Jellyfin.Api.Models.EnvironmentDtos +namespace Jellyfin.Api.Models.EnvironmentDtos; + +/// <summary> +/// Default directory browser info. +/// </summary> +public class DefaultDirectoryBrowserInfoDto { /// <summary> - /// Default directory browser info. + /// Gets or sets the path. /// </summary> - public class DefaultDirectoryBrowserInfoDto - { - /// <summary> - /// Gets or sets the path. - /// </summary> - public string? Path { get; set; } - } + public string? Path { get; set; } } diff --git a/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs b/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs index 418c11c2d..c54205bfa 100644 --- a/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs +++ b/Jellyfin.Api/Models/EnvironmentDtos/ValidatePathDto.cs @@ -1,23 +1,22 @@ -namespace Jellyfin.Api.Models.EnvironmentDtos +namespace Jellyfin.Api.Models.EnvironmentDtos; + +/// <summary> +/// Validate path object. +/// </summary> +public class ValidatePathDto { /// <summary> - /// Validate path object. + /// Gets or sets a value indicating whether validate if path is writable. /// </summary> - public class ValidatePathDto - { - /// <summary> - /// Gets or sets a value indicating whether validate if path is writable. - /// </summary> - public bool ValidateWritable { get; set; } + public bool ValidateWritable { get; set; } - /// <summary> - /// Gets or sets the path. - /// </summary> - public string? Path { get; set; } + /// <summary> + /// Gets or sets the path. + /// </summary> + public string? Path { get; set; } - /// <summary> - /// Gets or sets is path file. - /// </summary> - public bool? IsFile { get; set; } - } + /// <summary> + /// Gets or sets is path file. + /// </summary> + public bool? IsFile { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs index 358434434..6401522f6 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionInfoDto.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.LibraryDtos +namespace Jellyfin.Api.Models.LibraryDtos; + +/// <summary> +/// Library option info dto. +/// </summary> +public class LibraryOptionInfoDto { /// <summary> - /// Library option info dto. + /// Gets or sets name. /// </summary> - public class LibraryOptionInfoDto - { - /// <summary> - /// Gets or sets name. - /// </summary> - public string? Name { get; set; } + public string? Name { get; set; } - /// <summary> - /// Gets or sets a value indicating whether default enabled. - /// </summary> - public bool DefaultEnabled { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether default enabled. + /// </summary> + public bool DefaultEnabled { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs index 7de44aa65..78efacd94 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryOptionsResultDto.cs @@ -1,31 +1,30 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Api.Models.LibraryDtos +namespace Jellyfin.Api.Models.LibraryDtos; + +/// <summary> +/// Library options result dto. +/// </summary> +public class LibraryOptionsResultDto { /// <summary> - /// Library options result dto. + /// Gets or sets the metadata savers. /// </summary> - public class LibraryOptionsResultDto - { - /// <summary> - /// Gets or sets the metadata savers. - /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> MetadataSavers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + public IReadOnlyList<LibraryOptionInfoDto> MetadataSavers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); - /// <summary> - /// Gets or sets the metadata readers. - /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> MetadataReaders { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + /// <summary> + /// Gets or sets the metadata readers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> MetadataReaders { get; set; } = Array.Empty<LibraryOptionInfoDto>(); - /// <summary> - /// Gets or sets the subtitle fetchers. - /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> SubtitleFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + /// <summary> + /// Gets or sets the subtitle fetchers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> SubtitleFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); - /// <summary> - /// Gets or sets the type options. - /// </summary> - public IReadOnlyList<LibraryTypeOptionsDto> TypeOptions { get; set; } = Array.Empty<LibraryTypeOptionsDto>(); - } + /// <summary> + /// Gets or sets the type options. + /// </summary> + public IReadOnlyList<LibraryTypeOptionsDto> TypeOptions { get; set; } = Array.Empty<LibraryTypeOptionsDto>(); } diff --git a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs index 20f45196d..125a6746e 100644 --- a/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/LibraryTypeOptionsDto.cs @@ -3,36 +3,35 @@ using System.Collections.Generic; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; -namespace Jellyfin.Api.Models.LibraryDtos +namespace Jellyfin.Api.Models.LibraryDtos; + +/// <summary> +/// Library type options dto. +/// </summary> +public class LibraryTypeOptionsDto { /// <summary> - /// Library type options dto. + /// Gets or sets the type. /// </summary> - public class LibraryTypeOptionsDto - { - /// <summary> - /// Gets or sets the type. - /// </summary> - public string? Type { get; set; } + public string? Type { get; set; } - /// <summary> - /// Gets or sets the metadata fetchers. - /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + /// <summary> + /// Gets or sets the metadata fetchers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> MetadataFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); - /// <summary> - /// Gets or sets the image fetchers. - /// </summary> - public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); + /// <summary> + /// Gets or sets the image fetchers. + /// </summary> + public IReadOnlyList<LibraryOptionInfoDto> ImageFetchers { get; set; } = Array.Empty<LibraryOptionInfoDto>(); - /// <summary> - /// Gets or sets the supported image types. - /// </summary> - public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = Array.Empty<ImageType>(); + /// <summary> + /// Gets or sets the supported image types. + /// </summary> + public IReadOnlyList<ImageType> SupportedImageTypes { get; set; } = Array.Empty<ImageType>(); - /// <summary> - /// Gets or sets the default image options. - /// </summary> - public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = Array.Empty<ImageOption>(); - } + /// <summary> + /// Gets or sets the default image options. + /// </summary> + public IReadOnlyList<ImageOption> DefaultImageOptions { get; set; } = Array.Empty<ImageOption>(); } diff --git a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs index f93638898..b34e0bba5 100644 --- a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoDto.cs @@ -1,16 +1,15 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Api.Models.LibraryDtos +namespace Jellyfin.Api.Models.LibraryDtos; + +/// <summary> +/// Media Update Info Dto. +/// </summary> +public class MediaUpdateInfoDto { /// <summary> - /// Media Update Info Dto. + /// Gets or sets the list of updates. /// </summary> - public class MediaUpdateInfoDto - { - /// <summary> - /// Gets or sets the list of updates. - /// </summary> - public IReadOnlyList<MediaUpdateInfoPathDto> Updates { get; set; } = Array.Empty<MediaUpdateInfoPathDto>(); - } + public IReadOnlyList<MediaUpdateInfoPathDto> Updates { get; set; } = Array.Empty<MediaUpdateInfoPathDto>(); } diff --git a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs index 852315b92..5bbaea669 100644 --- a/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs +++ b/Jellyfin.Api/Models/LibraryDtos/MediaUpdateInfoPathDto.cs @@ -1,19 +1,18 @@ -namespace Jellyfin.Api.Models.LibraryDtos +namespace Jellyfin.Api.Models.LibraryDtos; + +/// <summary> +/// The media update info path. +/// </summary> +public class MediaUpdateInfoPathDto { /// <summary> - /// The media update info path. + /// Gets or sets media path. /// </summary> - public class MediaUpdateInfoPathDto - { - /// <summary> - /// Gets or sets media path. - /// </summary> - public string? Path { get; set; } + public string? Path { get; set; } - /// <summary> - /// Gets or sets media update type. - /// Created, Modified, Deleted. - /// </summary> - public string? UpdateType { get; set; } - } + /// <summary> + /// Gets or sets media update type. + /// Created, Modified, Deleted. + /// </summary> + public string? UpdateType { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryStructureDto/AddVirtualFolderDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/AddVirtualFolderDto.cs index ab68d5223..16d3f65c9 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/AddVirtualFolderDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/AddVirtualFolderDto.cs @@ -1,15 +1,14 @@ using MediaBrowser.Model.Configuration; -namespace Jellyfin.Api.Models.LibraryStructureDto +namespace Jellyfin.Api.Models.LibraryStructureDto; + +/// <summary> +/// Add virtual folder dto. +/// </summary> +public class AddVirtualFolderDto { /// <summary> - /// Add virtual folder dto. + /// Gets or sets library options. /// </summary> - public class AddVirtualFolderDto - { - /// <summary> - /// Gets or sets library options. - /// </summary> - public LibraryOptions? LibraryOptions { get; set; } - } + public LibraryOptions? LibraryOptions { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs index 8b26ec317..94ffc5238 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs @@ -1,27 +1,26 @@ using System.ComponentModel.DataAnnotations; using MediaBrowser.Model.Configuration; -namespace Jellyfin.Api.Models.LibraryStructureDto +namespace Jellyfin.Api.Models.LibraryStructureDto; + +/// <summary> +/// Media Path dto. +/// </summary> +public class MediaPathDto { /// <summary> - /// Media Path dto. + /// Gets or sets the name of the library. /// </summary> - public class MediaPathDto - { - /// <summary> - /// Gets or sets the name of the library. - /// </summary> - [Required] - public string? Name { get; set; } + [Required] + public string? Name { get; set; } - /// <summary> - /// Gets or sets the path to add. - /// </summary> - public string? Path { get; set; } + /// <summary> + /// Gets or sets the path to add. + /// </summary> + public string? Path { get; set; } - /// <summary> - /// Gets or sets the path info. - /// </summary> - public MediaPathInfo? PathInfo { get; set; } - } + /// <summary> + /// Gets or sets the path info. + /// </summary> + public MediaPathInfo? PathInfo { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryStructureDto/UpdateLibraryOptionsDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/UpdateLibraryOptionsDto.cs index c78ed51f7..225c7c7bc 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/UpdateLibraryOptionsDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/UpdateLibraryOptionsDto.cs @@ -1,21 +1,20 @@ using System; using MediaBrowser.Model.Configuration; -namespace Jellyfin.Api.Models.LibraryStructureDto +namespace Jellyfin.Api.Models.LibraryStructureDto; + +/// <summary> +/// Update library options dto. +/// </summary> +public class UpdateLibraryOptionsDto { /// <summary> - /// Update library options dto. + /// Gets or sets the library item id. /// </summary> - public class UpdateLibraryOptionsDto - { - /// <summary> - /// Gets or sets the library item id. - /// </summary> - public Guid Id { get; set; } + public Guid Id { get; set; } - /// <summary> - /// Gets or sets library options. - /// </summary> - public LibraryOptions? LibraryOptions { get; set; } - } + /// <summary> + /// Gets or sets library options. + /// </summary> + public LibraryOptions? LibraryOptions { get; set; } } diff --git a/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs index fbd4985f9..a4d33f3b9 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/UpdateMediaPathRequestDto.cs @@ -1,23 +1,22 @@ using System.ComponentModel.DataAnnotations; using MediaBrowser.Model.Configuration; -namespace Jellyfin.Api.Models.LibraryStructureDto +namespace Jellyfin.Api.Models.LibraryStructureDto; + +/// <summary> +/// Update library options dto. +/// </summary> +public class UpdateMediaPathRequestDto { /// <summary> - /// Update library options dto. + /// Gets or sets the library name. /// </summary> - public class UpdateMediaPathRequestDto - { - /// <summary> - /// Gets or sets the library name. - /// </summary> - [Required] - public string Name { get; set; } = null!; + [Required] + public string Name { get; set; } = null!; - /// <summary> - /// Gets or sets library folder path information. - /// </summary> - [Required] - public MediaPathInfo PathInfo { get; set; } = null!; - } + /// <summary> + /// Gets or sets library folder path information. + /// </summary> + [Required] + public MediaPathInfo PathInfo { get; set; } = null!; } diff --git a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs index e293c461c..75222ed01 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs @@ -1,34 +1,32 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; -namespace Jellyfin.Api.Models.LiveTvDtos +namespace Jellyfin.Api.Models.LiveTvDtos; + +/// <summary> +/// Channel mapping options dto. +/// </summary> +public class ChannelMappingOptionsDto { /// <summary> - /// Channel mapping options dto. + /// Gets or sets list of tuner channels. /// </summary> - public class ChannelMappingOptionsDto - { - /// <summary> - /// Gets or sets list of tuner channels. - /// </summary> - required public IReadOnlyList<TunerChannelMapping> TunerChannels { get; set; } + required public IReadOnlyList<TunerChannelMapping> TunerChannels { get; set; } - /// <summary> - /// Gets or sets list of provider channels. - /// </summary> - required public IReadOnlyList<NameIdPair> ProviderChannels { get; set; } + /// <summary> + /// Gets or sets list of provider channels. + /// </summary> + required public IReadOnlyList<NameIdPair> ProviderChannels { get; set; } - /// <summary> - /// Gets or sets list of mappings. - /// </summary> - public IReadOnlyList<NameValuePair> Mappings { get; set; } = Array.Empty<NameValuePair>(); + /// <summary> + /// Gets or sets list of mappings. + /// </summary> + public IReadOnlyList<NameValuePair> Mappings { get; set; } = Array.Empty<NameValuePair>(); - /// <summary> - /// Gets or sets provider name. - /// </summary> - public string? ProviderName { get; set; } - } + /// <summary> + /// Gets or sets provider name. + /// </summary> + public string? ProviderName { get; set; } } diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs index 411e4c550..5e7dd689e 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs @@ -6,174 +6,173 @@ using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; -namespace Jellyfin.Api.Models.LiveTvDtos +namespace Jellyfin.Api.Models.LiveTvDtos; + +/// <summary> +/// Get programs dto. +/// </summary> +public class GetProgramsDto { /// <summary> - /// Get programs dto. - /// </summary> - public class GetProgramsDto - { - /// <summary> - /// Gets or sets the channels to return guide information for. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>(); - - /// <summary> - /// Gets or sets optional. Filter by user id. - /// </summary> - public Guid UserId { get; set; } - - /// <summary> - /// Gets or sets the minimum premiere start date. - /// Optional. - /// </summary> - public DateTime? MinStartDate { get; set; } - - /// <summary> - /// Gets or sets filter by programs that have completed airing, or not. - /// Optional. - /// </summary> - public bool? HasAired { get; set; } - - /// <summary> - /// Gets or sets filter by programs that are currently airing, or not. - /// Optional. - /// </summary> - public bool? IsAiring { get; set; } - - /// <summary> - /// Gets or sets the maximum premiere start date. - /// Optional. - /// </summary> - public DateTime? MaxStartDate { get; set; } - - /// <summary> - /// Gets or sets the minimum premiere end date. - /// Optional. - /// </summary> - public DateTime? MinEndDate { get; set; } - - /// <summary> - /// Gets or sets the maximum premiere end date. - /// Optional. - /// </summary> - public DateTime? MaxEndDate { get; set; } - - /// <summary> - /// Gets or sets filter for movies. - /// Optional. - /// </summary> - public bool? IsMovie { get; set; } - - /// <summary> - /// Gets or sets filter for series. - /// Optional. - /// </summary> - public bool? IsSeries { get; set; } - - /// <summary> - /// Gets or sets filter for news. - /// Optional. - /// </summary> - public bool? IsNews { get; set; } - - /// <summary> - /// Gets or sets filter for kids. - /// Optional. - /// </summary> - public bool? IsKids { get; set; } - - /// <summary> - /// Gets or sets filter for sports. - /// Optional. - /// </summary> - public bool? IsSports { get; set; } - - /// <summary> - /// Gets or sets the record index to start at. All items with a lower index will be dropped from the results. - /// Optional. - /// </summary> - public int? StartIndex { get; set; } - - /// <summary> - /// Gets or sets the maximum number of records to return. - /// Optional. - /// </summary> - public int? Limit { get; set; } - - /// <summary> - /// Gets or sets specify one or more sort orders, comma delimited. Options: Name, StartDate. - /// Optional. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<string> SortBy { get; set; } = Array.Empty<string>(); - - /// <summary> - /// Gets or sets sort Order - Ascending,Descending. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<SortOrder> SortOrder { get; set; } = Array.Empty<SortOrder>(); - - /// <summary> - /// Gets or sets the genres to return guide information for. - /// </summary> - [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))] - public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>(); - - /// <summary> - /// Gets or sets the genre ids to return guide information for. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>(); - - /// <summary> - /// Gets or sets include image information in output. - /// Optional. - /// </summary> - public bool? EnableImages { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether retrieve total record count. - /// </summary> - public bool EnableTotalRecordCount { get; set; } = true; - - /// <summary> - /// Gets or sets the max number of images to return, per image type. - /// Optional. - /// </summary> - public int? ImageTypeLimit { get; set; } - - /// <summary> - /// Gets or sets the image types to include in the output. - /// Optional. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<ImageType> EnableImageTypes { get; set; } = Array.Empty<ImageType>(); - - /// <summary> - /// Gets or sets include user data. - /// Optional. - /// </summary> - public bool? EnableUserData { get; set; } - - /// <summary> - /// Gets or sets filter by series timer id. - /// Optional. - /// </summary> - public string? SeriesTimerId { get; set; } - - /// <summary> - /// Gets or sets filter by library series id. - /// Optional. - /// </summary> - public Guid LibrarySeriesId { get; set; } - - /// <summary> - /// Gets or sets specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. - /// Optional. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<ItemFields> Fields { get; set; } = Array.Empty<ItemFields>(); - } + /// Gets or sets the channels to return guide information for. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>(); + + /// <summary> + /// Gets or sets optional. Filter by user id. + /// </summary> + public Guid UserId { get; set; } + + /// <summary> + /// Gets or sets the minimum premiere start date. + /// Optional. + /// </summary> + public DateTime? MinStartDate { get; set; } + + /// <summary> + /// Gets or sets filter by programs that have completed airing, or not. + /// Optional. + /// </summary> + public bool? HasAired { get; set; } + + /// <summary> + /// Gets or sets filter by programs that are currently airing, or not. + /// Optional. + /// </summary> + public bool? IsAiring { get; set; } + + /// <summary> + /// Gets or sets the maximum premiere start date. + /// Optional. + /// </summary> + public DateTime? MaxStartDate { get; set; } + + /// <summary> + /// Gets or sets the minimum premiere end date. + /// Optional. + /// </summary> + public DateTime? MinEndDate { get; set; } + + /// <summary> + /// Gets or sets the maximum premiere end date. + /// Optional. + /// </summary> + public DateTime? MaxEndDate { get; set; } + + /// <summary> + /// Gets or sets filter for movies. + /// Optional. + /// </summary> + public bool? IsMovie { get; set; } + + /// <summary> + /// Gets or sets filter for series. + /// Optional. + /// </summary> + public bool? IsSeries { get; set; } + + /// <summary> + /// Gets or sets filter for news. + /// Optional. + /// </summary> + public bool? IsNews { get; set; } + + /// <summary> + /// Gets or sets filter for kids. + /// Optional. + /// </summary> + public bool? IsKids { get; set; } + + /// <summary> + /// Gets or sets filter for sports. + /// Optional. + /// </summary> + public bool? IsSports { get; set; } + + /// <summary> + /// Gets or sets the record index to start at. All items with a lower index will be dropped from the results. + /// Optional. + /// </summary> + public int? StartIndex { get; set; } + + /// <summary> + /// Gets or sets the maximum number of records to return. + /// Optional. + /// </summary> + public int? Limit { get; set; } + + /// <summary> + /// Gets or sets specify one or more sort orders, comma delimited. Options: Name, StartDate. + /// Optional. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<string> SortBy { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets sort Order - Ascending,Descending. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<SortOrder> SortOrder { get; set; } = Array.Empty<SortOrder>(); + + /// <summary> + /// Gets or sets the genres to return guide information for. + /// </summary> + [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))] + public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>(); + + /// <summary> + /// Gets or sets the genre ids to return guide information for. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>(); + + /// <summary> + /// Gets or sets include image information in output. + /// Optional. + /// </summary> + public bool? EnableImages { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether retrieve total record count. + /// </summary> + public bool EnableTotalRecordCount { get; set; } = true; + + /// <summary> + /// Gets or sets the max number of images to return, per image type. + /// Optional. + /// </summary> + public int? ImageTypeLimit { get; set; } + + /// <summary> + /// Gets or sets the image types to include in the output. + /// Optional. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<ImageType> EnableImageTypes { get; set; } = Array.Empty<ImageType>(); + + /// <summary> + /// Gets or sets include user data. + /// Optional. + /// </summary> + public bool? EnableUserData { get; set; } + + /// <summary> + /// Gets or sets filter by series timer id. + /// Optional. + /// </summary> + public string? SeriesTimerId { get; set; } + + /// <summary> + /// Gets or sets filter by library series id. + /// Optional. + /// </summary> + public Guid LibrarySeriesId { get; set; } + + /// <summary> + /// Gets or sets specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines. + /// Optional. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<ItemFields> Fields { get; set; } = Array.Empty<ItemFields>(); } diff --git a/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs b/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs index e7501bd9f..2dbaece5e 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/SetChannelMappingDto.cs @@ -1,28 +1,27 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.LiveTvDtos +namespace Jellyfin.Api.Models.LiveTvDtos; + +/// <summary> +/// Set channel mapping dto. +/// </summary> +public class SetChannelMappingDto { /// <summary> - /// Set channel mapping dto. + /// Gets or sets the provider id. /// </summary> - public class SetChannelMappingDto - { - /// <summary> - /// Gets or sets the provider id. - /// </summary> - [Required] - public string ProviderId { get; set; } = string.Empty; + [Required] + public string ProviderId { get; set; } = string.Empty; - /// <summary> - /// Gets or sets the tuner channel id. - /// </summary> - [Required] - public string TunerChannelId { get; set; } = string.Empty; + /// <summary> + /// Gets or sets the tuner channel id. + /// </summary> + [Required] + public string TunerChannelId { get; set; } = string.Empty; - /// <summary> - /// Gets or sets the provider channel id. - /// </summary> - [Required] - public string ProviderChannelId { get; set; } = string.Empty; - } + /// <summary> + /// Gets or sets the provider channel id. + /// </summary> + [Required] + public string ProviderChannelId { get; set; } = string.Empty; } diff --git a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs index 704542326..99b3f7020 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/OpenLiveStreamDto.cs @@ -3,76 +3,75 @@ using System.Collections.Generic; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.MediaInfo; -namespace Jellyfin.Api.Models.MediaInfoDtos +namespace Jellyfin.Api.Models.MediaInfoDtos; + +/// <summary> +/// Open live stream dto. +/// </summary> +public class OpenLiveStreamDto { /// <summary> - /// Open live stream dto. + /// Gets or sets the open token. /// </summary> - public class OpenLiveStreamDto - { - /// <summary> - /// Gets or sets the open token. - /// </summary> - public string? OpenToken { get; set; } + public string? OpenToken { get; set; } - /// <summary> - /// Gets or sets the user id. - /// </summary> - public Guid? UserId { get; set; } + /// <summary> + /// Gets or sets the user id. + /// </summary> + public Guid? UserId { get; set; } - /// <summary> - /// Gets or sets the play session id. - /// </summary> - public string? PlaySessionId { get; set; } + /// <summary> + /// Gets or sets the play session id. + /// </summary> + public string? PlaySessionId { get; set; } - /// <summary> - /// Gets or sets the max streaming bitrate. - /// </summary> - public int? MaxStreamingBitrate { get; set; } + /// <summary> + /// Gets or sets the max streaming bitrate. + /// </summary> + public int? MaxStreamingBitrate { get; set; } - /// <summary> - /// Gets or sets the start time in ticks. - /// </summary> - public long? StartTimeTicks { get; set; } + /// <summary> + /// Gets or sets the start time in ticks. + /// </summary> + public long? StartTimeTicks { get; set; } - /// <summary> - /// Gets or sets the audio stream index. - /// </summary> - public int? AudioStreamIndex { get; set; } + /// <summary> + /// Gets or sets the audio stream index. + /// </summary> + public int? AudioStreamIndex { get; set; } - /// <summary> - /// Gets or sets the subtitle stream index. - /// </summary> - public int? SubtitleStreamIndex { get; set; } + /// <summary> + /// Gets or sets the subtitle stream index. + /// </summary> + public int? SubtitleStreamIndex { get; set; } - /// <summary> - /// Gets or sets the max audio channels. - /// </summary> - public int? MaxAudioChannels { get; set; } + /// <summary> + /// Gets or sets the max audio channels. + /// </summary> + public int? MaxAudioChannels { get; set; } - /// <summary> - /// Gets or sets the item id. - /// </summary> - public Guid? ItemId { get; set; } + /// <summary> + /// Gets or sets the item id. + /// </summary> + public Guid? ItemId { get; set; } - /// <summary> - /// Gets or sets a value indicating whether to enable direct play. - /// </summary> - public bool? EnableDirectPlay { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to enable direct play. + /// </summary> + public bool? EnableDirectPlay { get; set; } - /// <summary> - /// Gets or sets a value indicating whether to enale direct stream. - /// </summary> - public bool? EnableDirectStream { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to enale direct stream. + /// </summary> + public bool? EnableDirectStream { get; set; } - /// <summary> - /// Gets or sets the device profile. - /// </summary> - public DeviceProfile? DeviceProfile { get; set; } + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } - /// <summary> - /// Gets or sets the device play protocols. - /// </summary> - public IReadOnlyList<MediaProtocol> DirectPlayProtocols { get; set; } = Array.Empty<MediaProtocol>(); - } + /// <summary> + /// Gets or sets the device play protocols. + /// </summary> + public IReadOnlyList<MediaProtocol> DirectPlayProtocols { get; set; } = Array.Empty<MediaProtocol>(); } diff --git a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs index c6bd5e56e..0ef1867cd 100644 --- a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs +++ b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs @@ -1,86 +1,85 @@ using System; using MediaBrowser.Model.Dlna; -namespace Jellyfin.Api.Models.MediaInfoDtos +namespace Jellyfin.Api.Models.MediaInfoDtos; + +/// <summary> +/// Plabyback info dto. +/// </summary> +public class PlaybackInfoDto { /// <summary> - /// Plabyback info dto. + /// Gets or sets the playback userId. + /// </summary> + public Guid? UserId { get; set; } + + /// <summary> + /// Gets or sets the max streaming bitrate. + /// </summary> + public int? MaxStreamingBitrate { get; set; } + + /// <summary> + /// Gets or sets the start time in ticks. + /// </summary> + public long? StartTimeTicks { get; set; } + + /// <summary> + /// Gets or sets the audio stream index. + /// </summary> + public int? AudioStreamIndex { get; set; } + + /// <summary> + /// Gets or sets the subtitle stream index. + /// </summary> + public int? SubtitleStreamIndex { get; set; } + + /// <summary> + /// Gets or sets the max audio channels. + /// </summary> + public int? MaxAudioChannels { get; set; } + + /// <summary> + /// Gets or sets the media source id. + /// </summary> + public string? MediaSourceId { get; set; } + + /// <summary> + /// Gets or sets the live stream id. + /// </summary> + public string? LiveStreamId { get; set; } + + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable direct play. + /// </summary> + public bool? EnableDirectPlay { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable direct stream. + /// </summary> + public bool? EnableDirectStream { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable transcoding. + /// </summary> + public bool? EnableTranscoding { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable video stream copy. + /// </summary> + public bool? AllowVideoStreamCopy { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to allow audio stream copy. + /// </summary> + public bool? AllowAudioStreamCopy { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to auto open the live stream. /// </summary> - public class PlaybackInfoDto - { - /// <summary> - /// Gets or sets the playback userId. - /// </summary> - public Guid? UserId { get; set; } - - /// <summary> - /// Gets or sets the max streaming bitrate. - /// </summary> - public int? MaxStreamingBitrate { get; set; } - - /// <summary> - /// Gets or sets the start time in ticks. - /// </summary> - public long? StartTimeTicks { get; set; } - - /// <summary> - /// Gets or sets the audio stream index. - /// </summary> - public int? AudioStreamIndex { get; set; } - - /// <summary> - /// Gets or sets the subtitle stream index. - /// </summary> - public int? SubtitleStreamIndex { get; set; } - - /// <summary> - /// Gets or sets the max audio channels. - /// </summary> - public int? MaxAudioChannels { get; set; } - - /// <summary> - /// Gets or sets the media source id. - /// </summary> - public string? MediaSourceId { get; set; } - - /// <summary> - /// Gets or sets the live stream id. - /// </summary> - public string? LiveStreamId { get; set; } - - /// <summary> - /// Gets or sets the device profile. - /// </summary> - public DeviceProfile? DeviceProfile { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable direct play. - /// </summary> - public bool? EnableDirectPlay { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable direct stream. - /// </summary> - public bool? EnableDirectStream { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable transcoding. - /// </summary> - public bool? EnableTranscoding { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable video stream copy. - /// </summary> - public bool? AllowVideoStreamCopy { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to allow audio stream copy. - /// </summary> - public bool? AllowAudioStreamCopy { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to auto open the live stream. - /// </summary> - public bool? AutoOpenLiveStream { get; set; } - } + public bool? AutoOpenLiveStream { get; set; } } diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs index 9060500c8..480ddab09 100644 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs @@ -6,279 +6,278 @@ using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dto; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Models.PlaybackDtos +namespace Jellyfin.Api.Models.PlaybackDtos; + +/// <summary> +/// Class TranscodingJob. +/// </summary> +public class TranscodingJobDto : IDisposable { /// <summary> - /// Class TranscodingJob. + /// The process lock. + /// </summary> + [SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] + [SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] + public readonly object ProcessLock = new object(); + + /// <summary> + /// Timer lock. /// </summary> - public class TranscodingJobDto : IDisposable + private readonly object _timerLock = new object(); + + /// <summary> + /// Initializes a new instance of the <see cref="TranscodingJobDto"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobDto}"/> interface.</param> + public TranscodingJobDto(ILogger<TranscodingJobDto> logger) { - /// <summary> - /// The process lock. - /// </summary> - [SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] - [SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")] - public readonly object ProcessLock = new object(); - - /// <summary> - /// Timer lock. - /// </summary> - private readonly object _timerLock = new object(); - - /// <summary> - /// Initializes a new instance of the <see cref="TranscodingJobDto"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobDto}"/> interface.</param> - public TranscodingJobDto(ILogger<TranscodingJobDto> logger) - { - Logger = logger; - } + Logger = logger; + } + + /// <summary> + /// Gets or sets the play session identifier. + /// </summary> + /// <value>The play session identifier.</value> + public string? PlaySessionId { get; set; } + + /// <summary> + /// Gets or sets the live stream identifier. + /// </summary> + /// <value>The live stream identifier.</value> + public string? LiveStreamId { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether is live output. + /// </summary> + public bool IsLiveOutput { get; set; } + + /// <summary> + /// Gets or sets the path. + /// </summary> + /// <value>The path.</value> + public MediaSourceInfo? MediaSource { get; set; } + + /// <summary> + /// Gets or sets path. + /// </summary> + public string? Path { get; set; } + + /// <summary> + /// Gets or sets the type. + /// </summary> + /// <value>The type.</value> + public TranscodingJobType Type { get; set; } + + /// <summary> + /// Gets or sets the process. + /// </summary> + /// <value>The process.</value> + public Process? Process { get; set; } + + /// <summary> + /// Gets logger. + /// </summary> + public ILogger<TranscodingJobDto> Logger { get; private set; } + + /// <summary> + /// Gets or sets the active request count. + /// </summary> + /// <value>The active request count.</value> + public int ActiveRequestCount { get; set; } + + /// <summary> + /// Gets or sets the kill timer. + /// </summary> + /// <value>The kill timer.</value> + private Timer? KillTimer { get; set; } + + /// <summary> + /// Gets or sets device id. + /// </summary> + public string? DeviceId { get; set; } + + /// <summary> + /// Gets or sets cancellation token source. + /// </summary> + public CancellationTokenSource? CancellationTokenSource { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether has exited. + /// </summary> + public bool HasExited { get; set; } + + /// <summary> + /// Gets or sets exit code. + /// </summary> + public int ExitCode { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether is user paused. + /// </summary> + public bool IsUserPaused { get; set; } + + /// <summary> + /// Gets or sets id. + /// </summary> + public string? Id { get; set; } + + /// <summary> + /// Gets or sets framerate. + /// </summary> + public float? Framerate { get; set; } + + /// <summary> + /// Gets or sets completion percentage. + /// </summary> + public double? CompletionPercentage { get; set; } - /// <summary> - /// Gets or sets the play session identifier. - /// </summary> - /// <value>The play session identifier.</value> - public string? PlaySessionId { get; set; } - - /// <summary> - /// Gets or sets the live stream identifier. - /// </summary> - /// <value>The live stream identifier.</value> - public string? LiveStreamId { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether is live output. - /// </summary> - public bool IsLiveOutput { get; set; } - - /// <summary> - /// Gets or sets the path. - /// </summary> - /// <value>The path.</value> - public MediaSourceInfo? MediaSource { get; set; } - - /// <summary> - /// Gets or sets path. - /// </summary> - public string? Path { get; set; } - - /// <summary> - /// Gets or sets the type. - /// </summary> - /// <value>The type.</value> - public TranscodingJobType Type { get; set; } - - /// <summary> - /// Gets or sets the process. - /// </summary> - /// <value>The process.</value> - public Process? Process { get; set; } - - /// <summary> - /// Gets logger. - /// </summary> - public ILogger<TranscodingJobDto> Logger { get; private set; } - - /// <summary> - /// Gets or sets the active request count. - /// </summary> - /// <value>The active request count.</value> - public int ActiveRequestCount { get; set; } - - /// <summary> - /// Gets or sets the kill timer. - /// </summary> - /// <value>The kill timer.</value> - private Timer? KillTimer { get; set; } - - /// <summary> - /// Gets or sets device id. - /// </summary> - public string? DeviceId { get; set; } - - /// <summary> - /// Gets or sets cancellation token source. - /// </summary> - public CancellationTokenSource? CancellationTokenSource { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether has exited. - /// </summary> - public bool HasExited { get; set; } - - /// <summary> - /// Gets or sets exit code. - /// </summary> - public int ExitCode { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether is user paused. - /// </summary> - public bool IsUserPaused { get; set; } - - /// <summary> - /// Gets or sets id. - /// </summary> - public string? Id { get; set; } - - /// <summary> - /// Gets or sets framerate. - /// </summary> - public float? Framerate { get; set; } - - /// <summary> - /// Gets or sets completion percentage. - /// </summary> - public double? CompletionPercentage { get; set; } - - /// <summary> - /// Gets or sets bytes downloaded. - /// </summary> - public long BytesDownloaded { get; set; } - - /// <summary> - /// Gets or sets bytes transcoded. - /// </summary> - public long? BytesTranscoded { get; set; } - - /// <summary> - /// Gets or sets bit rate. - /// </summary> - public int? BitRate { get; set; } - - /// <summary> - /// Gets or sets transcoding position ticks. - /// </summary> - public long? TranscodingPositionTicks { get; set; } - - /// <summary> - /// Gets or sets download position ticks. - /// </summary> - public long? DownloadPositionTicks { get; set; } - - /// <summary> - /// Gets or sets transcoding throttler. - /// </summary> - public TranscodingThrottler? TranscodingThrottler { get; set; } - - /// <summary> - /// Gets or sets last ping date. - /// </summary> - public DateTime LastPingDate { get; set; } - - /// <summary> - /// Gets or sets ping timeout. - /// </summary> - public int PingTimeout { get; set; } - - /// <summary> - /// Stop kill timer. - /// </summary> - public void StopKillTimer() + /// <summary> + /// Gets or sets bytes downloaded. + /// </summary> + public long BytesDownloaded { get; set; } + + /// <summary> + /// Gets or sets bytes transcoded. + /// </summary> + public long? BytesTranscoded { get; set; } + + /// <summary> + /// Gets or sets bit rate. + /// </summary> + public int? BitRate { get; set; } + + /// <summary> + /// Gets or sets transcoding position ticks. + /// </summary> + public long? TranscodingPositionTicks { get; set; } + + /// <summary> + /// Gets or sets download position ticks. + /// </summary> + public long? DownloadPositionTicks { get; set; } + + /// <summary> + /// Gets or sets transcoding throttler. + /// </summary> + public TranscodingThrottler? TranscodingThrottler { get; set; } + + /// <summary> + /// Gets or sets last ping date. + /// </summary> + public DateTime LastPingDate { get; set; } + + /// <summary> + /// Gets or sets ping timeout. + /// </summary> + public int PingTimeout { get; set; } + + /// <summary> + /// Stop kill timer. + /// </summary> + public void StopKillTimer() + { + lock (_timerLock) { - lock (_timerLock) - { - KillTimer?.Change(Timeout.Infinite, Timeout.Infinite); - } + KillTimer?.Change(Timeout.Infinite, Timeout.Infinite); } + } - /// <summary> - /// Dispose kill timer. - /// </summary> - public void DisposeKillTimer() + /// <summary> + /// Dispose kill timer. + /// </summary> + public void DisposeKillTimer() + { + lock (_timerLock) { - lock (_timerLock) + if (KillTimer is not null) { - if (KillTimer is not null) - { - KillTimer.Dispose(); - KillTimer = null; - } + KillTimer.Dispose(); + KillTimer = null; } } + } + + /// <summary> + /// Start kill timer. + /// </summary> + /// <param name="callback">Callback action.</param> + public void StartKillTimer(Action<object?> callback) + { + StartKillTimer(callback, PingTimeout); + } - /// <summary> - /// Start kill timer. - /// </summary> - /// <param name="callback">Callback action.</param> - public void StartKillTimer(Action<object?> callback) + /// <summary> + /// Start kill timer. + /// </summary> + /// <param name="callback">Callback action.</param> + /// <param name="intervalMs">Callback interval.</param> + public void StartKillTimer(Action<object?> callback, int intervalMs) + { + if (HasExited) { - StartKillTimer(callback, PingTimeout); + return; } - /// <summary> - /// Start kill timer. - /// </summary> - /// <param name="callback">Callback action.</param> - /// <param name="intervalMs">Callback interval.</param> - public void StartKillTimer(Action<object?> callback, int intervalMs) + lock (_timerLock) { - if (HasExited) + if (KillTimer is null) { - return; + Logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite); } - - lock (_timerLock) + else { - if (KillTimer is null) - { - Logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite); - } - else - { - Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer.Change(intervalMs, Timeout.Infinite); - } + Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer.Change(intervalMs, Timeout.Infinite); } } + } - /// <summary> - /// Change kill timer if started. - /// </summary> - public void ChangeKillTimerIfStarted() + /// <summary> + /// Change kill timer if started. + /// </summary> + public void ChangeKillTimerIfStarted() + { + if (HasExited) { - if (HasExited) - { - return; - } + return; + } - lock (_timerLock) + lock (_timerLock) + { + if (KillTimer is not null) { - if (KillTimer is not null) - { - var intervalMs = PingTimeout; + var intervalMs = PingTimeout; - Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); - KillTimer.Change(intervalMs, Timeout.Infinite); - } + Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer.Change(intervalMs, Timeout.Infinite); } } + } - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - /// <summary> - /// Dispose all resources. - /// </summary> - /// <param name="disposing">Whether to dispose all resources.</param> - protected virtual void Dispose(bool disposing) + /// <summary> + /// Dispose all resources. + /// </summary> + /// <param name="disposing">Whether to dispose all resources.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) { - if (disposing) - { - Process?.Dispose(); - Process = null; - KillTimer?.Dispose(); - KillTimer = null; - CancellationTokenSource?.Dispose(); - CancellationTokenSource = null; - TranscodingThrottler?.Dispose(); - TranscodingThrottler = null; - } + Process?.Dispose(); + Process = null; + KillTimer?.Dispose(); + KillTimer = null; + CancellationTokenSource?.Dispose(); + CancellationTokenSource = null; + TranscodingThrottler?.Dispose(); + TranscodingThrottler = null; } } } diff --git a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs index 9c4e377cd..b577c4ea6 100644 --- a/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs +++ b/Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs @@ -7,214 +7,213 @@ using MediaBrowser.Model.Configuration; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.Models.PlaybackDtos +namespace Jellyfin.Api.Models.PlaybackDtos; + +/// <summary> +/// Transcoding throttler. +/// </summary> +public class TranscodingThrottler : IDisposable { + private readonly TranscodingJobDto _job; + private readonly ILogger<TranscodingThrottler> _logger; + private readonly IConfigurationManager _config; + private readonly IFileSystem _fileSystem; + private readonly IMediaEncoder _mediaEncoder; + private Timer? _timer; + private bool _isPaused; + /// <summary> - /// Transcoding throttler. + /// Initializes a new instance of the <see cref="TranscodingThrottler"/> class. /// </summary> - public class TranscodingThrottler : IDisposable + /// <param name="job">Transcoding job dto.</param> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingThrottler}"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> + public TranscodingThrottler(TranscodingJobDto job, ILogger<TranscodingThrottler> logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder) { - private readonly TranscodingJobDto _job; - private readonly ILogger<TranscodingThrottler> _logger; - private readonly IConfigurationManager _config; - private readonly IFileSystem _fileSystem; - private readonly IMediaEncoder _mediaEncoder; - private Timer? _timer; - private bool _isPaused; - - /// <summary> - /// Initializes a new instance of the <see cref="TranscodingThrottler"/> class. - /// </summary> - /// <param name="job">Transcoding job dto.</param> - /// <param name="logger">Instance of the <see cref="ILogger{TranscodingThrottler}"/> interface.</param> - /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> - /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> - /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - public TranscodingThrottler(TranscodingJobDto job, ILogger<TranscodingThrottler> logger, IConfigurationManager config, IFileSystem fileSystem, IMediaEncoder mediaEncoder) - { - _job = job; - _logger = logger; - _config = config; - _fileSystem = fileSystem; - _mediaEncoder = mediaEncoder; - } + _job = job; + _logger = logger; + _config = config; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + } - /// <summary> - /// Start timer. - /// </summary> - public void Start() - { - _timer = new Timer(TimerCallback, null, 5000, 5000); - } + /// <summary> + /// Start timer. + /// </summary> + public void Start() + { + _timer = new Timer(TimerCallback, null, 5000, 5000); + } - /// <summary> - /// Unpause transcoding. - /// </summary> - /// <returns>A <see cref="Task"/>.</returns> - public async Task UnpauseTranscoding() + /// <summary> + /// Unpause transcoding. + /// </summary> + /// <returns>A <see cref="Task"/>.</returns> + public async Task UnpauseTranscoding() + { + if (_isPaused) { - if (_isPaused) - { - _logger.LogDebug("Sending resume command to ffmpeg"); + _logger.LogDebug("Sending resume command to ffmpeg"); - try - { - var resumeKey = _mediaEncoder.IsPkeyPauseSupported ? "u" : Environment.NewLine; - await _job.Process!.StandardInput.WriteAsync(resumeKey).ConfigureAwait(false); - _isPaused = false; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error resuming transcoding"); - } + try + { + var resumeKey = _mediaEncoder.IsPkeyPauseSupported ? "u" : Environment.NewLine; + await _job.Process!.StandardInput.WriteAsync(resumeKey).ConfigureAwait(false); + _isPaused = false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resuming transcoding"); } } + } - /// <summary> - /// Stop throttler. - /// </summary> - /// <returns>A <see cref="Task"/>.</returns> - public async Task Stop() + /// <summary> + /// Stop throttler. + /// </summary> + /// <returns>A <see cref="Task"/>.</returns> + public async Task Stop() + { + DisposeTimer(); + await UnpauseTranscoding().ConfigureAwait(false); + } + + /// <summary> + /// Dispose throttler. + /// </summary> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose throttler. + /// </summary> + /// <param name="disposing">Disposing.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) { DisposeTimer(); - await UnpauseTranscoding().ConfigureAwait(false); } + } - /// <summary> - /// Dispose throttler. - /// </summary> - public void Dispose() + private EncodingOptions GetOptions() + { + return _config.GetEncodingOptions(); + } + + private async void TimerCallback(object? state) + { + if (_job.HasExited) { - Dispose(true); - GC.SuppressFinalize(this); + DisposeTimer(); + return; } - /// <summary> - /// Dispose throttler. - /// </summary> - /// <param name="disposing">Disposing.</param> - protected virtual void Dispose(bool disposing) + var options = GetOptions(); + + if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds)) { - if (disposing) - { - DisposeTimer(); - } + await PauseTranscoding().ConfigureAwait(false); } - - private EncodingOptions GetOptions() + else { - return _config.GetEncodingOptions(); + await UnpauseTranscoding().ConfigureAwait(false); } + } - private async void TimerCallback(object? state) + private async Task PauseTranscoding() + { + if (!_isPaused) { - if (_job.HasExited) - { - DisposeTimer(); - return; - } + var pauseKey = _mediaEncoder.IsPkeyPauseSupported ? "p" : "c"; - var options = GetOptions(); + _logger.LogDebug("Sending pause command [{Key}] to ffmpeg", pauseKey); - if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds)) + try { - await PauseTranscoding().ConfigureAwait(false); + await _job.Process!.StandardInput.WriteAsync(pauseKey).ConfigureAwait(false); + _isPaused = true; } - else + catch (Exception ex) { - await UnpauseTranscoding().ConfigureAwait(false); + _logger.LogError(ex, "Error pausing transcoding"); } } + } - private async Task PauseTranscoding() + private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds) + { + var bytesDownloaded = job.BytesDownloaded; + var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0; + var downloadPositionTicks = job.DownloadPositionTicks ?? 0; + + var path = job.Path ?? throw new ArgumentException("Path can't be null."); + + var gapLengthInTicks = TimeSpan.FromSeconds(thresholdSeconds).Ticks; + + if (downloadPositionTicks > 0 && transcodingPositionTicks > 0) { - if (!_isPaused) - { - var pauseKey = _mediaEncoder.IsPkeyPauseSupported ? "p" : "c"; + // HLS - time-based consideration - _logger.LogDebug("Sending pause command [{Key}] to ffmpeg", pauseKey); + var targetGap = gapLengthInTicks; + var gap = transcodingPositionTicks - downloadPositionTicks; - try - { - await _job.Process!.StandardInput.WriteAsync(pauseKey).ConfigureAwait(false); - _isPaused = true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error pausing transcoding"); - } + if (gap < targetGap) + { + _logger.LogDebug("Not throttling transcoder gap {0} target gap {1}", gap, targetGap); + return false; } + + _logger.LogDebug("Throttling transcoder gap {0} target gap {1}", gap, targetGap); + return true; } - private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds) + if (bytesDownloaded > 0 && transcodingPositionTicks > 0) { - var bytesDownloaded = job.BytesDownloaded; - var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0; - var downloadPositionTicks = job.DownloadPositionTicks ?? 0; - - var path = job.Path ?? throw new ArgumentException("Path can't be null."); - - var gapLengthInTicks = TimeSpan.FromSeconds(thresholdSeconds).Ticks; + // Progressive Streaming - byte-based consideration - if (downloadPositionTicks > 0 && transcodingPositionTicks > 0) + try { - // HLS - time-based consideration + var bytesTranscoded = job.BytesTranscoded ?? _fileSystem.GetFileInfo(path).Length; - var targetGap = gapLengthInTicks; - var gap = transcodingPositionTicks - downloadPositionTicks; + // Estimate the bytes the transcoder should be ahead + double gapFactor = gapLengthInTicks; + gapFactor /= transcodingPositionTicks; + var targetGap = bytesTranscoded * gapFactor; + + var gap = bytesTranscoded - bytesDownloaded; if (gap < targetGap) { - _logger.LogDebug("Not throttling transcoder gap {0} target gap {1}", gap, targetGap); + _logger.LogDebug("Not throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); return false; } - _logger.LogDebug("Throttling transcoder gap {0} target gap {1}", gap, targetGap); + _logger.LogDebug("Throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); return true; } - - if (bytesDownloaded > 0 && transcodingPositionTicks > 0) + catch (Exception ex) { - // Progressive Streaming - byte-based consideration - - try - { - var bytesTranscoded = job.BytesTranscoded ?? _fileSystem.GetFileInfo(path).Length; - - // Estimate the bytes the transcoder should be ahead - double gapFactor = gapLengthInTicks; - gapFactor /= transcodingPositionTicks; - var targetGap = bytesTranscoded * gapFactor; - - var gap = bytesTranscoded - bytesDownloaded; - - if (gap < targetGap) - { - _logger.LogDebug("Not throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); - return false; - } - - _logger.LogDebug("Throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting output size"); - return false; - } + _logger.LogError(ex, "Error getting output size"); + return false; } - - _logger.LogDebug("No throttle data for {Path}", path); - return false; } - private void DisposeTimer() + _logger.LogDebug("No throttle data for {Path}", path); + return false; + } + + private void DisposeTimer() + { + if (_timer is not null) { - if (_timer is not null) - { - _timer.Dispose(); - _timer = null; - } + _timer.Dispose(); + _timer = null; } } } diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs index 0761b2085..1fba32c5b 100644 --- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -3,32 +3,31 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Extensions.Json.Converters; -namespace Jellyfin.Api.Models.PlaylistDtos +namespace Jellyfin.Api.Models.PlaylistDtos; + +/// <summary> +/// Create new playlist dto. +/// </summary> +public class CreatePlaylistDto { /// <summary> - /// Create new playlist dto. + /// Gets or sets the name of the new playlist. /// </summary> - public class CreatePlaylistDto - { - /// <summary> - /// Gets or sets the name of the new playlist. - /// </summary> - public string? Name { get; set; } + public string? Name { get; set; } - /// <summary> - /// Gets or sets item ids to add to the playlist. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>(); + /// <summary> + /// Gets or sets item ids to add to the playlist. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>(); - /// <summary> - /// Gets or sets the user id. - /// </summary> - public Guid? UserId { get; set; } + /// <summary> + /// Gets or sets the user id. + /// </summary> + public Guid? UserId { get; set; } - /// <summary> - /// Gets or sets the media type. - /// </summary> - public string? MediaType { get; set; } - } + /// <summary> + /// Gets or sets the media type. + /// </summary> + public string? MediaType { get; set; } } diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs index fa62472e1..b88be33b2 100644 --- a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs +++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs @@ -5,84 +5,83 @@ using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Session; -namespace Jellyfin.Api.Models.SessionDtos +namespace Jellyfin.Api.Models.SessionDtos; + +/// <summary> +/// Client capabilities dto. +/// </summary> +public class ClientCapabilitiesDto { /// <summary> - /// Client capabilities dto. + /// Gets or sets the list of playable media types. /// </summary> - public class ClientCapabilitiesDto - { - /// <summary> - /// Gets or sets the list of playable media types. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<string> PlayableMediaTypes { get; set; } = Array.Empty<string>(); + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<string> PlayableMediaTypes { get; set; } = Array.Empty<string>(); - /// <summary> - /// Gets or sets the list of supported commands. - /// </summary> - [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = Array.Empty<GeneralCommandType>(); + /// <summary> + /// Gets or sets the list of supported commands. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = Array.Empty<GeneralCommandType>(); - /// <summary> - /// Gets or sets a value indicating whether session supports media control. - /// </summary> - public bool SupportsMediaControl { get; set; } + /// <summary> + /// Gets or sets a value indicating whether session supports media control. + /// </summary> + public bool SupportsMediaControl { get; set; } - /// <summary> - /// Gets or sets a value indicating whether session supports content uploading. - /// </summary> - public bool SupportsContentUploading { get; set; } + /// <summary> + /// Gets or sets a value indicating whether session supports content uploading. + /// </summary> + public bool SupportsContentUploading { get; set; } - /// <summary> - /// Gets or sets the message callback url. - /// </summary> - public string? MessageCallbackUrl { get; set; } + /// <summary> + /// Gets or sets the message callback url. + /// </summary> + public string? MessageCallbackUrl { get; set; } - /// <summary> - /// Gets or sets a value indicating whether session supports a persistent identifier. - /// </summary> - public bool SupportsPersistentIdentifier { get; set; } + /// <summary> + /// Gets or sets a value indicating whether session supports a persistent identifier. + /// </summary> + public bool SupportsPersistentIdentifier { get; set; } - /// <summary> - /// Gets or sets a value indicating whether session supports sync. - /// </summary> - public bool SupportsSync { get; set; } + /// <summary> + /// Gets or sets a value indicating whether session supports sync. + /// </summary> + public bool SupportsSync { get; set; } - /// <summary> - /// Gets or sets the device profile. - /// </summary> - public DeviceProfile? DeviceProfile { get; set; } + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } - /// <summary> - /// Gets or sets the app store url. - /// </summary> - public string? AppStoreUrl { get; set; } + /// <summary> + /// Gets or sets the app store url. + /// </summary> + public string? AppStoreUrl { get; set; } - /// <summary> - /// Gets or sets the icon url. - /// </summary> - public string? IconUrl { get; set; } + /// <summary> + /// Gets or sets the icon url. + /// </summary> + public string? IconUrl { get; set; } - /// <summary> - /// Convert the dto to the full <see cref="ClientCapabilities"/> model. - /// </summary> - /// <returns>The converted <see cref="ClientCapabilities"/> model.</returns> - public ClientCapabilities ToClientCapabilities() + /// <summary> + /// Convert the dto to the full <see cref="ClientCapabilities"/> model. + /// </summary> + /// <returns>The converted <see cref="ClientCapabilities"/> model.</returns> + public ClientCapabilities ToClientCapabilities() + { + return new ClientCapabilities { - return new ClientCapabilities - { - PlayableMediaTypes = PlayableMediaTypes, - SupportedCommands = SupportedCommands, - SupportsMediaControl = SupportsMediaControl, - SupportsContentUploading = SupportsContentUploading, - MessageCallbackUrl = MessageCallbackUrl, - SupportsPersistentIdentifier = SupportsPersistentIdentifier, - SupportsSync = SupportsSync, - DeviceProfile = DeviceProfile, - AppStoreUrl = AppStoreUrl, - IconUrl = IconUrl - }; - } + PlayableMediaTypes = PlayableMediaTypes, + SupportedCommands = SupportedCommands, + SupportsMediaControl = SupportsMediaControl, + SupportsContentUploading = SupportsContentUploading, + MessageCallbackUrl = MessageCallbackUrl, + SupportsPersistentIdentifier = SupportsPersistentIdentifier, + SupportsSync = SupportsSync, + DeviceProfile = DeviceProfile, + AppStoreUrl = AppStoreUrl, + IconUrl = IconUrl + }; } } diff --git a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs index a5f012245..402707819 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupConfigurationDto.cs @@ -1,23 +1,22 @@ -namespace Jellyfin.Api.Models.StartupDtos +namespace Jellyfin.Api.Models.StartupDtos; + +/// <summary> +/// The startup configuration DTO. +/// </summary> +public class StartupConfigurationDto { /// <summary> - /// The startup configuration DTO. + /// Gets or sets UI language culture. /// </summary> - public class StartupConfigurationDto - { - /// <summary> - /// Gets or sets UI language culture. - /// </summary> - public string? UICulture { get; set; } + public string? UICulture { get; set; } - /// <summary> - /// Gets or sets the metadata country code. - /// </summary> - public string? MetadataCountryCode { get; set; } + /// <summary> + /// Gets or sets the metadata country code. + /// </summary> + public string? MetadataCountryCode { get; set; } - /// <summary> - /// Gets or sets the preferred language for the metadata. - /// </summary> - public string? PreferredMetadataLanguage { get; set; } - } + /// <summary> + /// Gets or sets the preferred language for the metadata. + /// </summary> + public string? PreferredMetadataLanguage { get; set; } } diff --git a/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs index 4027ba41a..0e7be24c4 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs @@ -1,22 +1,21 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.StartupDtos +namespace Jellyfin.Api.Models.StartupDtos; + +/// <summary> +/// Startup remote access dto. +/// </summary> +public class StartupRemoteAccessDto { /// <summary> - /// Startup remote access dto. + /// Gets or sets a value indicating whether enable remote access. /// </summary> - public class StartupRemoteAccessDto - { - /// <summary> - /// Gets or sets a value indicating whether enable remote access. - /// </summary> - [Required] - public bool EnableRemoteAccess { get; set; } + [Required] + public bool EnableRemoteAccess { get; set; } - /// <summary> - /// Gets or sets a value indicating whether enable automatic port mapping. - /// </summary> - [Required] - public bool EnableAutomaticPortMapping { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether enable automatic port mapping. + /// </summary> + [Required] + public bool EnableAutomaticPortMapping { get; set; } } diff --git a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs index e4c973548..f473bbcef 100644 --- a/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs +++ b/Jellyfin.Api/Models/StartupDtos/StartupUserDto.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.StartupDtos +namespace Jellyfin.Api.Models.StartupDtos; + +/// <summary> +/// The startup user DTO. +/// </summary> +public class StartupUserDto { /// <summary> - /// The startup user DTO. + /// Gets or sets the username. /// </summary> - public class StartupUserDto - { - /// <summary> - /// Gets or sets the username. - /// </summary> - public string? Name { get; set; } + public string? Name { get; set; } - /// <summary> - /// Gets or sets the user's password. - /// </summary> - public string? Password { get; set; } - } + /// <summary> + /// Gets or sets the user's password. + /// </summary> + public string? Password { get; set; } } diff --git a/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs index 3791fadbe..4f1abb1ff 100644 --- a/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/HlsAudioRequestDto.cs @@ -1,13 +1,12 @@ -namespace Jellyfin.Api.Models.StreamingDtos +namespace Jellyfin.Api.Models.StreamingDtos; + +/// <summary> +/// The hls video request dto. +/// </summary> +public class HlsAudioRequestDto : StreamingRequestDto { /// <summary> - /// The hls video request dto. + /// Gets or sets a value indicating whether enable adaptive bitrate streaming. /// </summary> - public class HlsAudioRequestDto : StreamingRequestDto - { - /// <summary> - /// Gets or sets a value indicating whether enable adaptive bitrate streaming. - /// </summary> - public bool EnableAdaptiveBitrateStreaming { get; set; } - } + public bool EnableAdaptiveBitrateStreaming { get; set; } } diff --git a/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs index 7a4be091b..1cd3d0132 100644 --- a/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/HlsVideoRequestDto.cs @@ -1,13 +1,12 @@ -namespace Jellyfin.Api.Models.StreamingDtos +namespace Jellyfin.Api.Models.StreamingDtos; + +/// <summary> +/// The hls video request dto. +/// </summary> +public class HlsVideoRequestDto : VideoRequestDto { /// <summary> - /// The hls video request dto. + /// Gets or sets a value indicating whether enable adaptive bitrate streaming. /// </summary> - public class HlsVideoRequestDto : VideoRequestDto - { - /// <summary> - /// Gets or sets a value indicating whether enable adaptive bitrate streaming. - /// </summary> - public bool EnableAdaptiveBitrateStreaming { get; set; } - } + public bool EnableAdaptiveBitrateStreaming { get; set; } } diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs index 1fce1d20a..b75272d3f 100644 --- a/Jellyfin.Api/Models/StreamingDtos/StreamState.cs +++ b/Jellyfin.Api/Models/StreamingDtos/StreamState.cs @@ -5,192 +5,191 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Dlna; -namespace Jellyfin.Api.Models.StreamingDtos +namespace Jellyfin.Api.Models.StreamingDtos; + +/// <summary> +/// The stream state dto. +/// </summary> +public class StreamState : EncodingJobInfo, IDisposable { + private readonly IMediaSourceManager _mediaSourceManager; + private readonly TranscodingJobHelper _transcodingJobHelper; + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="StreamState" /> class. + /// </summary> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager" /> interface.</param> + /// <param name="transcodingType">The <see cref="TranscodingJobType" />.</param> + /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper" /> singleton.</param> + public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType, TranscodingJobHelper transcodingJobHelper) + : base(transcodingType) + { + _mediaSourceManager = mediaSourceManager; + _transcodingJobHelper = transcodingJobHelper; + } + + /// <summary> + /// Gets or sets the requested url. + /// </summary> + public string? RequestedUrl { get; set; } + /// <summary> - /// The stream state dto. + /// Gets or sets the request. /// </summary> - public class StreamState : EncodingJobInfo, IDisposable + public StreamingRequestDto Request { - private readonly IMediaSourceManager _mediaSourceManager; - private readonly TranscodingJobHelper _transcodingJobHelper; - private bool _disposed; - - /// <summary> - /// Initializes a new instance of the <see cref="StreamState" /> class. - /// </summary> - /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager" /> interface.</param> - /// <param name="transcodingType">The <see cref="TranscodingJobType" />.</param> - /// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper" /> singleton.</param> - public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType, TranscodingJobHelper transcodingJobHelper) - : base(transcodingType) + get => (StreamingRequestDto)BaseRequest; + set { - _mediaSourceManager = mediaSourceManager; - _transcodingJobHelper = transcodingJobHelper; + BaseRequest = value; + IsVideoRequest = VideoRequest is not null; } + } + + /// <summary> + /// Gets the video request. + /// </summary> + public VideoRequestDto? VideoRequest => Request as VideoRequestDto; + + /// <summary> + /// Gets or sets the direct stream provicer. + /// </summary> + /// <remarks> + /// Deprecated. + /// </remarks> + public IDirectStreamProvider? DirectStreamProvider { get; set; } + + /// <summary> + /// Gets or sets the path to wait for. + /// </summary> + public string? WaitForPath { get; set; } - /// <summary> - /// Gets or sets the requested url. - /// </summary> - public string? RequestedUrl { get; set; } + /// <summary> + /// Gets a value indicating whether the request outputs video. + /// </summary> + public bool IsOutputVideo => Request is VideoRequestDto; - /// <summary> - /// Gets or sets the request. - /// </summary> - public StreamingRequestDto Request + /// <summary> + /// Gets the segment length. + /// </summary> + public int SegmentLength + { + get { - get => (StreamingRequestDto)BaseRequest; - set + if (Request.SegmentLength.HasValue) { - BaseRequest = value; - IsVideoRequest = VideoRequest is not null; + return Request.SegmentLength.Value; } - } - /// <summary> - /// Gets the video request. - /// </summary> - public VideoRequestDto? VideoRequest => Request as VideoRequestDto; - - /// <summary> - /// Gets or sets the direct stream provicer. - /// </summary> - /// <remarks> - /// Deprecated. - /// </remarks> - public IDirectStreamProvider? DirectStreamProvider { get; set; } - - /// <summary> - /// Gets or sets the path to wait for. - /// </summary> - public string? WaitForPath { get; set; } - - /// <summary> - /// Gets a value indicating whether the request outputs video. - /// </summary> - public bool IsOutputVideo => Request is VideoRequestDto; - - /// <summary> - /// Gets the segment length. - /// </summary> - public int SegmentLength - { - get + if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) { - if (Request.SegmentLength.HasValue) + var userAgent = UserAgent ?? string.Empty; + + if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 + || userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) { - return Request.SegmentLength.Value; + return 6; } - if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) + if (IsSegmentedLiveStream) { - var userAgent = UserAgent ?? string.Empty; - - if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1 - || userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1 - || userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 - || userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 - || userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) - { - return 6; - } - - if (IsSegmentedLiveStream) - { - return 3; - } - - return 6; + return 3; } - return 3; + return 6; } + + return 3; } + } - /// <summary> - /// Gets the minimum number of segments. - /// </summary> - public int MinSegments + /// <summary> + /// Gets the minimum number of segments. + /// </summary> + public int MinSegments + { + get { - get + if (Request.MinSegments.HasValue) { - if (Request.MinSegments.HasValue) - { - return Request.MinSegments.Value; - } - - return SegmentLength >= 10 ? 2 : 3; + return Request.MinSegments.Value; } - } - /// <summary> - /// Gets or sets the user agent. - /// </summary> - public string? UserAgent { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to estimate the content length. - /// </summary> - public bool EstimateContentLength { get; set; } - - /// <summary> - /// Gets or sets the transcode seek info. - /// </summary> - public TranscodeSeekInfo TranscodeSeekInfo { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable dlna headers. - /// </summary> - public bool EnableDlnaHeaders { get; set; } - - /// <summary> - /// Gets or sets the device profile. - /// </summary> - public DeviceProfile? DeviceProfile { get; set; } - - /// <summary> - /// Gets or sets the transcoding job. - /// </summary> - public TranscodingJobDto? TranscodingJob { get; set; } - - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); + return SegmentLength >= 10 ? 2 : 3; } + } + + /// <summary> + /// Gets or sets the user agent. + /// </summary> + public string? UserAgent { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to estimate the content length. + /// </summary> + public bool EstimateContentLength { get; set; } + + /// <summary> + /// Gets or sets the transcode seek info. + /// </summary> + public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to enable dlna headers. + /// </summary> + public bool EnableDlnaHeaders { get; set; } + + /// <summary> + /// Gets or sets the device profile. + /// </summary> + public DeviceProfile? DeviceProfile { get; set; } - /// <inheritdoc /> - public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + /// <summary> + /// Gets or sets the transcoding job. + /// </summary> + public TranscodingJobDto? TranscodingJob { get; set; } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <inheritdoc /> + public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + { + _transcodingJobHelper.ReportTranscodingProgress(TranscodingJob!, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); + } + + /// <summary> + /// Disposes the stream state. + /// </summary> + /// <param name="disposing">Whether the object is currently being disposed.</param> + protected virtual void Dispose(bool disposing) + { + if (_disposed) { - _transcodingJobHelper.ReportTranscodingProgress(TranscodingJob!, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); + return; } - /// <summary> - /// Disposes the stream state. - /// </summary> - /// <param name="disposing">Whether the object is currently being disposed.</param> - protected virtual void Dispose(bool disposing) + if (disposing) { - if (_disposed) + // REVIEW: Is this the right place for this? + if (MediaSource.RequiresClosing + && string.IsNullOrWhiteSpace(Request.LiveStreamId) + && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) { - return; - } - - if (disposing) - { - // REVIEW: Is this the right place for this? - if (MediaSource.RequiresClosing - && string.IsNullOrWhiteSpace(Request.LiveStreamId) - && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) - { - _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); - } + _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); } + } - TranscodingJob = null; + TranscodingJob = null; - _disposed = true; - } + _disposed = true; } } diff --git a/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs index f8b0212b6..389d6006d 100644 --- a/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/StreamingRequestDto.cs @@ -1,55 +1,54 @@ using MediaBrowser.Controller.MediaEncoding; -namespace Jellyfin.Api.Models.StreamingDtos +namespace Jellyfin.Api.Models.StreamingDtos; + +/// <summary> +/// The audio streaming request dto. +/// </summary> +public class StreamingRequestDto : BaseEncodingJobOptions { /// <summary> - /// The audio streaming request dto. - /// </summary> - public class StreamingRequestDto : BaseEncodingJobOptions - { - /// <summary> - /// Gets or sets the device profile. - /// </summary> - public string? DeviceProfileId { get; set; } - - /// <summary> - /// Gets or sets the params. - /// </summary> - public string? Params { get; set; } - - /// <summary> - /// Gets or sets the play session id. - /// </summary> - public string? PlaySessionId { get; set; } - - /// <summary> - /// Gets or sets the tag. - /// </summary> - public string? Tag { get; set; } - - /// <summary> - /// Gets or sets the segment container. - /// </summary> - public string? SegmentContainer { get; set; } - - /// <summary> - /// Gets or sets the segment length. - /// </summary> - public int? SegmentLength { get; set; } - - /// <summary> - /// Gets or sets the min segments. - /// </summary> - public int? MinSegments { get; set; } - - /// <summary> - /// Gets or sets the position of the requested segment in ticks. - /// </summary> - public long CurrentRuntimeTicks { get; set; } - - /// <summary> - /// Gets or sets the actual segment length in ticks. - /// </summary> - public long ActualSegmentLengthTicks { get; set; } - } + /// Gets or sets the device profile. + /// </summary> + public string? DeviceProfileId { get; set; } + + /// <summary> + /// Gets or sets the params. + /// </summary> + public string? Params { get; set; } + + /// <summary> + /// Gets or sets the play session id. + /// </summary> + public string? PlaySessionId { get; set; } + + /// <summary> + /// Gets or sets the tag. + /// </summary> + public string? Tag { get; set; } + + /// <summary> + /// Gets or sets the segment container. + /// </summary> + public string? SegmentContainer { get; set; } + + /// <summary> + /// Gets or sets the segment length. + /// </summary> + public int? SegmentLength { get; set; } + + /// <summary> + /// Gets or sets the min segments. + /// </summary> + public int? MinSegments { get; set; } + + /// <summary> + /// Gets or sets the position of the requested segment in ticks. + /// </summary> + public long CurrentRuntimeTicks { get; set; } + + /// <summary> + /// Gets or sets the actual segment length in ticks. + /// </summary> + public long ActualSegmentLengthTicks { get; set; } } diff --git a/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs b/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs index cce2a89d4..60c529d4a 100644 --- a/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs +++ b/Jellyfin.Api/Models/StreamingDtos/VideoRequestDto.cs @@ -1,19 +1,18 @@ -namespace Jellyfin.Api.Models.StreamingDtos +namespace Jellyfin.Api.Models.StreamingDtos; + +/// <summary> +/// The video request dto. +/// </summary> +public class VideoRequestDto : StreamingRequestDto { /// <summary> - /// The video request dto. + /// Gets a value indicating whether this instance has fixed resolution. /// </summary> - public class VideoRequestDto : StreamingRequestDto - { - /// <summary> - /// Gets a value indicating whether this instance has fixed resolution. - /// </summary> - /// <value><c>true</c> if this instance has fixed resolution; otherwise, <c>false</c>.</value> - public bool HasFixedResolution => Width.HasValue || Height.HasValue; + /// <value><c>true</c> if this instance has fixed resolution; otherwise, <c>false</c>.</value> + public bool HasFixedResolution => Width.HasValue || Height.HasValue; - /// <summary> - /// Gets or sets a value indicating whether to enable subtitles in the manifest. - /// </summary> - public bool EnableSubtitlesInManifest { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether to enable subtitles in the manifest. + /// </summary> + public bool EnableSubtitlesInManifest { get; set; } } diff --git a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs index be0595798..3c903ea6b 100644 --- a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs +++ b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs @@ -1,34 +1,33 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.SubtitleDtos +namespace Jellyfin.Api.Models.SubtitleDtos; + +/// <summary> +/// Upload subtitles dto. +/// </summary> +public class UploadSubtitleDto { /// <summary> - /// Upload subtitles dto. + /// Gets or sets the subtitle language. /// </summary> - public class UploadSubtitleDto - { - /// <summary> - /// Gets or sets the subtitle language. - /// </summary> - [Required] - public string Language { get; set; } = string.Empty; + [Required] + public string Language { get; set; } = string.Empty; - /// <summary> - /// Gets or sets the subtitle format. - /// </summary> - [Required] - public string Format { get; set; } = string.Empty; + /// <summary> + /// Gets or sets the subtitle format. + /// </summary> + [Required] + public string Format { get; set; } = string.Empty; - /// <summary> - /// Gets or sets a value indicating whether the subtitle is forced. - /// </summary> - [Required] - public bool IsForced { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the subtitle is forced. + /// </summary> + [Required] + public bool IsForced { get; set; } - /// <summary> - /// Gets or sets the subtitle data. - /// </summary> - [Required] - public string Data { get; set; } = string.Empty; - } + /// <summary> + /// Gets or sets the subtitle data. + /// </summary> + [Required] + public string Data { get; set; } = string.Empty; } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs index 479c44084..e7613911e 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs @@ -1,42 +1,41 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class BufferRequestDto. +/// </summary> +public class BufferRequestDto { /// <summary> - /// Class BufferRequestDto. + /// Initializes a new instance of the <see cref="BufferRequestDto"/> class. /// </summary> - public class BufferRequestDto + public BufferRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="BufferRequestDto"/> class. - /// </summary> - public BufferRequestDto() - { - PlaylistItemId = Guid.Empty; - } + PlaylistItemId = Guid.Empty; + } - /// <summary> - /// Gets or sets when the request has been made by the client. - /// </summary> - /// <value>The date of the request.</value> - public DateTime When { get; set; } + /// <summary> + /// Gets or sets when the request has been made by the client. + /// </summary> + /// <value>The date of the request.</value> + public DateTime When { get; set; } - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long PositionTicks { get; set; } + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } - /// <summary> - /// Gets or sets a value indicating whether the client playback is unpaused. - /// </summary> - /// <value>The client playback status.</value> - public bool IsPlaying { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the client playback is unpaused. + /// </summary> + /// <value>The client playback status.</value> + public bool IsPlaying { get; set; } - /// <summary> - /// Gets or sets the playlist item identifier of the playing item. - /// </summary> - /// <value>The playlist item identifier.</value> - public Guid PlaylistItemId { get; set; } - } + /// <summary> + /// Gets or sets the playlist item identifier of the playing item. + /// </summary> + /// <value>The playlist item identifier.</value> + public Guid PlaylistItemId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs index 4c30b7be4..8ccd831bd 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs @@ -1,14 +1,13 @@ -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class IgnoreWaitRequestDto. +/// </summary> +public class IgnoreWaitRequestDto { /// <summary> - /// Class IgnoreWaitRequestDto. + /// Gets or sets a value indicating whether the client should be ignored. /// </summary> - public class IgnoreWaitRequestDto - { - /// <summary> - /// Gets or sets a value indicating whether the client should be ignored. - /// </summary> - /// <value>The client group-wait status.</value> - public bool IgnoreWait { get; set; } - } + /// <value>The client group-wait status.</value> + public bool IgnoreWait { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs index ed97b8d6a..89ba511af 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs @@ -1,16 +1,15 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class JoinGroupRequestDto. +/// </summary> +public class JoinGroupRequestDto { /// <summary> - /// Class JoinGroupRequestDto. + /// Gets or sets the group identifier. /// </summary> - public class JoinGroupRequestDto - { - /// <summary> - /// Gets or sets the group identifier. - /// </summary> - /// <value>The identifier of the group to join.</value> - public Guid GroupId { get; set; } - } + /// <value>The identifier of the group to join.</value> + public Guid GroupId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs index 3af25f3e3..220d147f2 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs @@ -1,30 +1,29 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class MovePlaylistItemRequestDto. +/// </summary> +public class MovePlaylistItemRequestDto { /// <summary> - /// Class MovePlaylistItemRequestDto. + /// Initializes a new instance of the <see cref="MovePlaylistItemRequestDto"/> class. /// </summary> - public class MovePlaylistItemRequestDto + public MovePlaylistItemRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="MovePlaylistItemRequestDto"/> class. - /// </summary> - public MovePlaylistItemRequestDto() - { - PlaylistItemId = Guid.Empty; - } + PlaylistItemId = Guid.Empty; + } - /// <summary> - /// Gets or sets the playlist identifier of the item. - /// </summary> - /// <value>The playlist identifier of the item.</value> - public Guid PlaylistItemId { get; set; } + /// <summary> + /// Gets or sets the playlist identifier of the item. + /// </summary> + /// <value>The playlist identifier of the item.</value> + public Guid PlaylistItemId { get; set; } - /// <summary> - /// Gets or sets the new position. - /// </summary> - /// <value>The new position.</value> - public int NewIndex { get; set; } - } + /// <summary> + /// Gets or sets the new position. + /// </summary> + /// <value>The new position.</value> + public int NewIndex { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs index 441d7be36..32a3bb444 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs @@ -1,22 +1,21 @@ -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class NewGroupRequestDto. +/// </summary> +public class NewGroupRequestDto { /// <summary> - /// Class NewGroupRequestDto. + /// Initializes a new instance of the <see cref="NewGroupRequestDto"/> class. /// </summary> - public class NewGroupRequestDto + public NewGroupRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="NewGroupRequestDto"/> class. - /// </summary> - public NewGroupRequestDto() - { - GroupName = string.Empty; - } - - /// <summary> - /// Gets or sets the group name. - /// </summary> - /// <value>The name of the new group.</value> - public string GroupName { get; set; } + GroupName = string.Empty; } + + /// <summary> + /// Gets or sets the group name. + /// </summary> + /// <value>The name of the new group.</value> + public string GroupName { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs index f59a93f13..b5223af5d 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs @@ -1,24 +1,23 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class NextItemRequestDto. +/// </summary> +public class NextItemRequestDto { /// <summary> - /// Class NextItemRequestDto. + /// Initializes a new instance of the <see cref="NextItemRequestDto"/> class. /// </summary> - public class NextItemRequestDto + public NextItemRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="NextItemRequestDto"/> class. - /// </summary> - public NextItemRequestDto() - { - PlaylistItemId = Guid.Empty; - } - - /// <summary> - /// Gets or sets the playing item identifier. - /// </summary> - /// <value>The playing item identifier.</value> - public Guid PlaylistItemId { get; set; } + PlaylistItemId = Guid.Empty; } + + /// <summary> + /// Gets or sets the playing item identifier. + /// </summary> + /// <value>The playing item identifier.</value> + public Guid PlaylistItemId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs index c4ac06856..f13395057 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs @@ -1,14 +1,13 @@ -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class PingRequestDto. +/// </summary> +public class PingRequestDto { /// <summary> - /// Class PingRequestDto. + /// Gets or sets the ping time. /// </summary> - public class PingRequestDto - { - /// <summary> - /// Gets or sets the ping time. - /// </summary> - /// <value>The ping time.</value> - public long Ping { get; set; } - } + /// <value>The ping time.</value> + public long Ping { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs index 844388cd9..e0edaf5e0 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs @@ -1,37 +1,36 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class PlayRequestDto. +/// </summary> +public class PlayRequestDto { /// <summary> - /// Class PlayRequestDto. + /// Initializes a new instance of the <see cref="PlayRequestDto"/> class. /// </summary> - public class PlayRequestDto + public PlayRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="PlayRequestDto"/> class. - /// </summary> - public PlayRequestDto() - { - PlayingQueue = Array.Empty<Guid>(); - } + PlayingQueue = Array.Empty<Guid>(); + } - /// <summary> - /// Gets or sets the playing queue. - /// </summary> - /// <value>The playing queue.</value> - public IReadOnlyList<Guid> PlayingQueue { get; set; } + /// <summary> + /// Gets or sets the playing queue. + /// </summary> + /// <value>The playing queue.</value> + public IReadOnlyList<Guid> PlayingQueue { get; set; } - /// <summary> - /// Gets or sets the position of the playing item in the queue. - /// </summary> - /// <value>The playing item position.</value> - public int PlayingItemPosition { get; set; } + /// <summary> + /// Gets or sets the position of the playing item in the queue. + /// </summary> + /// <value>The playing item position.</value> + public int PlayingItemPosition { get; set; } - /// <summary> - /// Gets or sets the start position ticks. - /// </summary> - /// <value>The start position ticks.</value> - public long StartPositionTicks { get; set; } - } + /// <summary> + /// Gets or sets the start position ticks. + /// </summary> + /// <value>The start position ticks.</value> + public long StartPositionTicks { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs index 7fd4a49be..f52bd7f46 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs @@ -1,24 +1,23 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class PreviousItemRequestDto. +/// </summary> +public class PreviousItemRequestDto { /// <summary> - /// Class PreviousItemRequestDto. + /// Initializes a new instance of the <see cref="PreviousItemRequestDto"/> class. /// </summary> - public class PreviousItemRequestDto + public PreviousItemRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="PreviousItemRequestDto"/> class. - /// </summary> - public PreviousItemRequestDto() - { - PlaylistItemId = Guid.Empty; - } - - /// <summary> - /// Gets or sets the playing item identifier. - /// </summary> - /// <value>The playing item identifier.</value> - public Guid PlaylistItemId { get; set; } + PlaylistItemId = Guid.Empty; } + + /// <summary> + /// Gets or sets the playing item identifier. + /// </summary> + /// <value>The playing item identifier.</value> + public Guid PlaylistItemId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs index 2b187f443..c2c2fba04 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs @@ -2,31 +2,30 @@ using System; using System.Collections.Generic; using MediaBrowser.Model.SyncPlay; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class QueueRequestDto. +/// </summary> +public class QueueRequestDto { /// <summary> - /// Class QueueRequestDto. + /// Initializes a new instance of the <see cref="QueueRequestDto"/> class. /// </summary> - public class QueueRequestDto + public QueueRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="QueueRequestDto"/> class. - /// </summary> - public QueueRequestDto() - { - ItemIds = Array.Empty<Guid>(); - } + ItemIds = Array.Empty<Guid>(); + } - /// <summary> - /// Gets or sets the items to enqueue. - /// </summary> - /// <value>The items to enqueue.</value> - public IReadOnlyList<Guid> ItemIds { get; set; } + /// <summary> + /// Gets or sets the items to enqueue. + /// </summary> + /// <value>The items to enqueue.</value> + public IReadOnlyList<Guid> ItemIds { get; set; } - /// <summary> - /// Gets or sets the mode in which to add the new items. - /// </summary> - /// <value>The enqueue mode.</value> - public GroupQueueMode Mode { get; set; } - } + /// <summary> + /// Gets or sets the mode in which to add the new items. + /// </summary> + /// <value>The enqueue mode.</value> + public GroupQueueMode Mode { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs index d9c193016..d8be75ef1 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs @@ -1,42 +1,41 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class ReadyRequest. +/// </summary> +public class ReadyRequestDto { /// <summary> - /// Class ReadyRequest. + /// Initializes a new instance of the <see cref="ReadyRequestDto"/> class. /// </summary> - public class ReadyRequestDto + public ReadyRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="ReadyRequestDto"/> class. - /// </summary> - public ReadyRequestDto() - { - PlaylistItemId = Guid.Empty; - } + PlaylistItemId = Guid.Empty; + } - /// <summary> - /// Gets or sets when the request has been made by the client. - /// </summary> - /// <value>The date of the request.</value> - public DateTime When { get; set; } + /// <summary> + /// Gets or sets when the request has been made by the client. + /// </summary> + /// <value>The date of the request.</value> + public DateTime When { get; set; } - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long PositionTicks { get; set; } + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } - /// <summary> - /// Gets or sets a value indicating whether the client playback is unpaused. - /// </summary> - /// <value>The client playback status.</value> - public bool IsPlaying { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the client playback is unpaused. + /// </summary> + /// <value>The client playback status.</value> + public bool IsPlaying { get; set; } - /// <summary> - /// Gets or sets the playlist item identifier of the playing item. - /// </summary> - /// <value>The playlist item identifier.</value> - public Guid PlaylistItemId { get; set; } - } + /// <summary> + /// Gets or sets the playlist item identifier of the playing item. + /// </summary> + /// <value>The playlist item identifier.</value> + public Guid PlaylistItemId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs index 226a584e1..2c7234272 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs @@ -1,37 +1,36 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class RemoveFromPlaylistRequestDto. +/// </summary> +public class RemoveFromPlaylistRequestDto { /// <summary> - /// Class RemoveFromPlaylistRequestDto. + /// Initializes a new instance of the <see cref="RemoveFromPlaylistRequestDto"/> class. /// </summary> - public class RemoveFromPlaylistRequestDto + public RemoveFromPlaylistRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="RemoveFromPlaylistRequestDto"/> class. - /// </summary> - public RemoveFromPlaylistRequestDto() - { - PlaylistItemIds = Array.Empty<Guid>(); - } + PlaylistItemIds = Array.Empty<Guid>(); + } - /// <summary> - /// Gets or sets the playlist identifiers of the items. Ignored when clearing the playlist. - /// </summary> - /// <value>The playlist identifiers of the items.</value> - public IReadOnlyList<Guid> PlaylistItemIds { get; set; } + /// <summary> + /// Gets or sets the playlist identifiers of the items. Ignored when clearing the playlist. + /// </summary> + /// <value>The playlist identifiers of the items.</value> + public IReadOnlyList<Guid> PlaylistItemIds { get; set; } - /// <summary> - /// Gets or sets a value indicating whether the entire playlist should be cleared. - /// </summary> - /// <value>Whether the entire playlist should be cleared.</value> - public bool ClearPlaylist { get; set; } + /// <summary> + /// Gets or sets a value indicating whether the entire playlist should be cleared. + /// </summary> + /// <value>Whether the entire playlist should be cleared.</value> + public bool ClearPlaylist { get; set; } - /// <summary> - /// Gets or sets a value indicating whether the playing item should be removed as well. Used only when clearing the playlist. - /// </summary> - /// <value>Whether the playing item should be removed as well.</value> - public bool ClearPlayingItem { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether the playing item should be removed as well. Used only when clearing the playlist. + /// </summary> + /// <value>Whether the playing item should be removed as well.</value> + public bool ClearPlayingItem { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs index b9af0be7f..f461417e9 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs @@ -1,14 +1,13 @@ -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class SeekRequestDto. +/// </summary> +public class SeekRequestDto { /// <summary> - /// Class SeekRequestDto. + /// Gets or sets the position ticks. /// </summary> - public class SeekRequestDto - { - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long PositionTicks { get; set; } - } + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs index b937679fc..40e665039 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs @@ -1,24 +1,23 @@ using System; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class SetPlaylistItemRequestDto. +/// </summary> +public class SetPlaylistItemRequestDto { /// <summary> - /// Class SetPlaylistItemRequestDto. + /// Initializes a new instance of the <see cref="SetPlaylistItemRequestDto"/> class. /// </summary> - public class SetPlaylistItemRequestDto + public SetPlaylistItemRequestDto() { - /// <summary> - /// Initializes a new instance of the <see cref="SetPlaylistItemRequestDto"/> class. - /// </summary> - public SetPlaylistItemRequestDto() - { - PlaylistItemId = Guid.Empty; - } - - /// <summary> - /// Gets or sets the playlist identifier of the playing item. - /// </summary> - /// <value>The playlist identifier of the playing item.</value> - public Guid PlaylistItemId { get; set; } + PlaylistItemId = Guid.Empty; } + + /// <summary> + /// Gets or sets the playlist identifier of the playing item. + /// </summary> + /// <value>The playlist identifier of the playing item.</value> + public Guid PlaylistItemId { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs index e748fc3e0..387d1ea77 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs @@ -1,16 +1,15 @@ using MediaBrowser.Model.SyncPlay; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class SetRepeatModeRequestDto. +/// </summary> +public class SetRepeatModeRequestDto { /// <summary> - /// Class SetRepeatModeRequestDto. + /// Gets or sets the repeat mode. /// </summary> - public class SetRepeatModeRequestDto - { - /// <summary> - /// Gets or sets the repeat mode. - /// </summary> - /// <value>The repeat mode.</value> - public GroupRepeatMode Mode { get; set; } - } + /// <value>The repeat mode.</value> + public GroupRepeatMode Mode { get; set; } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs index 0e427f4a4..a67e3958c 100644 --- a/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs @@ -1,16 +1,15 @@ using MediaBrowser.Model.SyncPlay; -namespace Jellyfin.Api.Models.SyncPlayDtos +namespace Jellyfin.Api.Models.SyncPlayDtos; + +/// <summary> +/// Class SetShuffleModeRequestDto. +/// </summary> +public class SetShuffleModeRequestDto { /// <summary> - /// Class SetShuffleModeRequestDto. + /// Gets or sets the shuffle mode. /// </summary> - public class SetShuffleModeRequestDto - { - /// <summary> - /// Gets or sets the shuffle mode. - /// </summary> - /// <value>The shuffle mode.</value> - public GroupShuffleMode Mode { get; set; } - } + /// <value>The shuffle mode.</value> + public GroupShuffleMode Mode { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs index 31208264f..70c18a98a 100644 --- a/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs +++ b/Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// The authenticate user by name request body. +/// </summary> +public class AuthenticateUserByName { /// <summary> - /// The authenticate user by name request body. + /// Gets or sets the username. /// </summary> - public class AuthenticateUserByName - { - /// <summary> - /// Gets or sets the username. - /// </summary> - public string? Username { get; set; } + public string? Username { get; set; } - /// <summary> - /// Gets or sets the plain text password. - /// </summary> - public string? Pw { get; set; } - } + /// <summary> + /// Gets or sets the plain text password. + /// </summary> + public string? Pw { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs b/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs index 1c88d3628..0503c5d57 100644 --- a/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs +++ b/Jellyfin.Api/Models/UserDtos/CreateUserByName.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// The create user by name request body. +/// </summary> +public class CreateUserByName { /// <summary> - /// The create user by name request body. + /// Gets or sets the username. /// </summary> - public class CreateUserByName - { - /// <summary> - /// Gets or sets the username. - /// </summary> - public string? Name { get; set; } + public string? Name { get; set; } - /// <summary> - /// Gets or sets the password. - /// </summary> - public string? Password { get; set; } - } + /// <summary> + /// Gets or sets the password. + /// </summary> + public string? Password { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/ForgotPasswordDto.cs b/Jellyfin.Api/Models/UserDtos/ForgotPasswordDto.cs index b31c6539c..ebe9297ea 100644 --- a/Jellyfin.Api/Models/UserDtos/ForgotPasswordDto.cs +++ b/Jellyfin.Api/Models/UserDtos/ForgotPasswordDto.cs @@ -1,16 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// Forgot Password request body DTO. +/// </summary> +public class ForgotPasswordDto { /// <summary> - /// Forgot Password request body DTO. + /// Gets or sets the entered username to have its password reset. /// </summary> - public class ForgotPasswordDto - { - /// <summary> - /// Gets or sets the entered username to have its password reset. - /// </summary> - [Required] - public string? EnteredUsername { get; set; } - } + [Required] + public string? EnteredUsername { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs b/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs index 62780e23c..2949efe29 100644 --- a/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs +++ b/Jellyfin.Api/Models/UserDtos/ForgotPasswordPinDto.cs @@ -1,16 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// Forgot Password Pin enter request body DTO. +/// </summary> +public class ForgotPasswordPinDto { /// <summary> - /// Forgot Password Pin enter request body DTO. + /// Gets or sets the entered pin to have the password reset. /// </summary> - public class ForgotPasswordPinDto - { - /// <summary> - /// Gets or sets the entered pin to have the password reset. - /// </summary> - [Required] - public string? Pin { get; set; } - } + [Required] + public string? Pin { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs index 9493c08c2..245002f80 100644 --- a/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs +++ b/Jellyfin.Api/Models/UserDtos/QuickConnectDto.cs @@ -1,16 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// The quick connect request body. +/// </summary> +public class QuickConnectDto { /// <summary> - /// The quick connect request body. + /// Gets or sets the quick connect secret. /// </summary> - public class QuickConnectDto - { - /// <summary> - /// Gets or sets the quick connect secret. - /// </summary> - [Required] - public string Secret { get; set; } = null!; - } + [Required] + public string Secret { get; set; } = null!; } diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs index 0a173ea1a..80b6203bc 100644 --- a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs +++ b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs @@ -1,23 +1,22 @@ -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// The update user easy password request body. +/// </summary> +public class UpdateUserEasyPassword { /// <summary> - /// The update user easy password request body. + /// Gets or sets the new sha1-hashed password. /// </summary> - public class UpdateUserEasyPassword - { - /// <summary> - /// Gets or sets the new sha1-hashed password. - /// </summary> - public string? NewPassword { get; set; } + public string? NewPassword { get; set; } - /// <summary> - /// Gets or sets the new password. - /// </summary> - public string? NewPw { get; set; } + /// <summary> + /// Gets or sets the new password. + /// </summary> + public string? NewPw { get; set; } - /// <summary> - /// Gets or sets a value indicating whether to reset the password. - /// </summary> - public bool ResetPassword { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether to reset the password. + /// </summary> + public bool ResetPassword { get; set; } } diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs index 8288dbbc4..5347fcc9a 100644 --- a/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs +++ b/Jellyfin.Api/Models/UserDtos/UpdateUserPassword.cs @@ -1,28 +1,27 @@ -namespace Jellyfin.Api.Models.UserDtos +namespace Jellyfin.Api.Models.UserDtos; + +/// <summary> +/// The update user password request body. +/// </summary> +public class UpdateUserPassword { /// <summary> - /// The update user password request body. + /// Gets or sets the current sha1-hashed password. /// </summary> - public class UpdateUserPassword - { - /// <summary> - /// Gets or sets the current sha1-hashed password. - /// </summary> - public string? CurrentPassword { get; set; } + public string? CurrentPassword { get; set; } - /// <summary> - /// Gets or sets the current plain text password. - /// </summary> - public string? CurrentPw { get; set; } + /// <summary> + /// Gets or sets the current plain text password. + /// </summary> + public string? CurrentPw { get; set; } - /// <summary> - /// Gets or sets the new plain text password. - /// </summary> - public string? NewPw { get; set; } + /// <summary> + /// Gets or sets the new plain text password. + /// </summary> + public string? NewPw { get; set; } - /// <summary> - /// Gets or sets a value indicating whether to reset the password. - /// </summary> - public bool ResetPassword { get; set; } - } + /// <summary> + /// Gets or sets a value indicating whether to reset the password. + /// </summary> + public bool ResetPassword { get; set; } } diff --git a/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs b/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs index 84b6b0958..314b6a324 100644 --- a/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs +++ b/Jellyfin.Api/Models/UserViewDtos/SpecialViewOptionDto.cs @@ -1,18 +1,17 @@ -namespace Jellyfin.Api.Models.UserViewDtos +namespace Jellyfin.Api.Models.UserViewDtos; + +/// <summary> +/// Special view option dto. +/// </summary> +public class SpecialViewOptionDto { /// <summary> - /// Special view option dto. + /// Gets or sets view option name. /// </summary> - public class SpecialViewOptionDto - { - /// <summary> - /// Gets or sets view option name. - /// </summary> - public string? Name { get; set; } + public string? Name { get; set; } - /// <summary> - /// Gets or sets view option id. - /// </summary> - public string? Id { get; set; } - } + /// <summary> + /// Gets or sets view option id. + /// </summary> + public string? Id { get; set; } } diff --git a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs index 288e03fcf..3eac81419 100644 --- a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs @@ -6,59 +6,58 @@ using MediaBrowser.Model.Activity; using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.WebSocketListeners +namespace Jellyfin.Api.WebSocketListeners; + +/// <summary> +/// Class SessionInfoWebSocketListener. +/// </summary> +public class ActivityLogWebSocketListener : BasePeriodicWebSocketListener<ActivityLogEntry[], WebSocketListenerState> { /// <summary> - /// Class SessionInfoWebSocketListener. + /// The _kernel. /// </summary> - public class ActivityLogWebSocketListener : BasePeriodicWebSocketListener<ActivityLogEntry[], WebSocketListenerState> - { - /// <summary> - /// The _kernel. - /// </summary> - private readonly IActivityManager _activityManager; + private readonly IActivityManager _activityManager; - /// <summary> - /// Initializes a new instance of the <see cref="ActivityLogWebSocketListener"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{ActivityLogWebSocketListener}"/> interface.</param> - /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> - public ActivityLogWebSocketListener(ILogger<ActivityLogWebSocketListener> logger, IActivityManager activityManager) - : base(logger) - { - _activityManager = activityManager; - _activityManager.EntryCreated += OnEntryCreated; - } + /// <summary> + /// Initializes a new instance of the <see cref="ActivityLogWebSocketListener"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{ActivityLogWebSocketListener}"/> interface.</param> + /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param> + public ActivityLogWebSocketListener(ILogger<ActivityLogWebSocketListener> logger, IActivityManager activityManager) + : base(logger) + { + _activityManager = activityManager; + _activityManager.EntryCreated += OnEntryCreated; + } - /// <inheritdoc /> - protected override SessionMessageType Type => SessionMessageType.ActivityLogEntry; + /// <inheritdoc /> + protected override SessionMessageType Type => SessionMessageType.ActivityLogEntry; - /// <inheritdoc /> - protected override SessionMessageType StartType => SessionMessageType.ActivityLogEntryStart; + /// <inheritdoc /> + protected override SessionMessageType StartType => SessionMessageType.ActivityLogEntryStart; - /// <inheritdoc /> - protected override SessionMessageType StopType => SessionMessageType.ActivityLogEntryStop; + /// <inheritdoc /> + protected override SessionMessageType StopType => SessionMessageType.ActivityLogEntryStop; - /// <summary> - /// Gets the data to send. - /// </summary> - /// <returns>Task{SystemInfo}.</returns> - protected override Task<ActivityLogEntry[]> GetDataToSend() - { - return Task.FromResult(Array.Empty<ActivityLogEntry>()); - } + /// <summary> + /// Gets the data to send. + /// </summary> + /// <returns>Task{SystemInfo}.</returns> + protected override Task<ActivityLogEntry[]> GetDataToSend() + { + return Task.FromResult(Array.Empty<ActivityLogEntry>()); + } - /// <inheritdoc /> - protected override void Dispose(bool dispose) - { - _activityManager.EntryCreated -= OnEntryCreated; + /// <inheritdoc /> + protected override void Dispose(bool dispose) + { + _activityManager.EntryCreated -= OnEntryCreated; - base.Dispose(dispose); - } + base.Dispose(dispose); + } - private void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) - { - SendData(true).GetAwaiter().GetResult(); - } + private void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) + { + SendData(true).GetAwaiter().GetResult(); } } diff --git a/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs index 7c6ce3273..a9df2d671 100644 --- a/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ScheduledTasksWebSocketListener.cs @@ -7,78 +7,77 @@ using MediaBrowser.Model.Session; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.WebSocketListeners +namespace Jellyfin.Api.WebSocketListeners; + +/// <summary> +/// Class ScheduledTasksWebSocketListener. +/// </summary> +public class ScheduledTasksWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<TaskInfo>, WebSocketListenerState> { /// <summary> - /// Class ScheduledTasksWebSocketListener. + /// Gets or sets the task manager. /// </summary> - public class ScheduledTasksWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<TaskInfo>, WebSocketListenerState> - { - /// <summary> - /// Gets or sets the task manager. - /// </summary> - /// <value>The task manager.</value> - private readonly ITaskManager _taskManager; + /// <value>The task manager.</value> + private readonly ITaskManager _taskManager; - /// <summary> - /// Initializes a new instance of the <see cref="ScheduledTasksWebSocketListener"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{ScheduledTasksWebSocketListener}"/> interface.</param> - /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param> - public ScheduledTasksWebSocketListener(ILogger<ScheduledTasksWebSocketListener> logger, ITaskManager taskManager) - : base(logger) - { - _taskManager = taskManager; + /// <summary> + /// Initializes a new instance of the <see cref="ScheduledTasksWebSocketListener"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{ScheduledTasksWebSocketListener}"/> interface.</param> + /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param> + public ScheduledTasksWebSocketListener(ILogger<ScheduledTasksWebSocketListener> logger, ITaskManager taskManager) + : base(logger) + { + _taskManager = taskManager; - _taskManager.TaskExecuting += OnTaskExecuting; - _taskManager.TaskCompleted += OnTaskCompleted; - } + _taskManager.TaskExecuting += OnTaskExecuting; + _taskManager.TaskCompleted += OnTaskCompleted; + } - /// <inheritdoc /> - protected override SessionMessageType Type => SessionMessageType.ScheduledTasksInfo; + /// <inheritdoc /> + protected override SessionMessageType Type => SessionMessageType.ScheduledTasksInfo; - /// <inheritdoc /> - protected override SessionMessageType StartType => SessionMessageType.ScheduledTasksInfoStart; + /// <inheritdoc /> + protected override SessionMessageType StartType => SessionMessageType.ScheduledTasksInfoStart; - /// <inheritdoc /> - protected override SessionMessageType StopType => SessionMessageType.ScheduledTasksInfoStop; + /// <inheritdoc /> + protected override SessionMessageType StopType => SessionMessageType.ScheduledTasksInfoStop; - /// <summary> - /// Gets the data to send. - /// </summary> - /// <returns>Task{IEnumerable{TaskInfo}}.</returns> - protected override Task<IEnumerable<TaskInfo>> GetDataToSend() - { - return Task.FromResult(_taskManager.ScheduledTasks - .OrderBy(i => i.Name) - .Select(ScheduledTaskHelpers.GetTaskInfo) - .Where(i => !i.IsHidden)); - } + /// <summary> + /// Gets the data to send. + /// </summary> + /// <returns>Task{IEnumerable{TaskInfo}}.</returns> + protected override Task<IEnumerable<TaskInfo>> GetDataToSend() + { + return Task.FromResult(_taskManager.ScheduledTasks + .OrderBy(i => i.Name) + .Select(ScheduledTaskHelpers.GetTaskInfo) + .Where(i => !i.IsHidden)); + } - /// <inheritdoc /> - protected override void Dispose(bool dispose) - { - _taskManager.TaskExecuting -= OnTaskExecuting; - _taskManager.TaskCompleted -= OnTaskCompleted; + /// <inheritdoc /> + protected override void Dispose(bool dispose) + { + _taskManager.TaskExecuting -= OnTaskExecuting; + _taskManager.TaskCompleted -= OnTaskCompleted; - base.Dispose(dispose); - } + base.Dispose(dispose); + } - private async void OnTaskCompleted(object? sender, TaskCompletionEventArgs e) - { - e.Task.TaskProgress -= OnTaskProgress; - await SendData(true).ConfigureAwait(false); - } + private async void OnTaskCompleted(object? sender, TaskCompletionEventArgs e) + { + e.Task.TaskProgress -= OnTaskProgress; + await SendData(true).ConfigureAwait(false); + } - private async void OnTaskExecuting(object? sender, GenericEventArgs<IScheduledTaskWorker> e) - { - await SendData(true).ConfigureAwait(false); - e.Argument.TaskProgress += OnTaskProgress; - } + private async void OnTaskExecuting(object? sender, GenericEventArgs<IScheduledTaskWorker> e) + { + await SendData(true).ConfigureAwait(false); + e.Argument.TaskProgress += OnTaskProgress; + } - private async void OnTaskProgress(object? sender, GenericEventArgs<double> e) - { - await SendData(false).ConfigureAwait(false); - } + private async void OnTaskProgress(object? sender, GenericEventArgs<double> e) + { + await SendData(false).ConfigureAwait(false); } } diff --git a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs index d996ac69f..0d8bf205c 100644 --- a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs @@ -6,99 +6,98 @@ using MediaBrowser.Controller.Session; using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; -namespace Jellyfin.Api.WebSocketListeners +namespace Jellyfin.Api.WebSocketListeners; + +/// <summary> +/// Class SessionInfoWebSocketListener. +/// </summary> +public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState> { + private readonly ISessionManager _sessionManager; + /// <summary> - /// Class SessionInfoWebSocketListener. + /// Initializes a new instance of the <see cref="SessionInfoWebSocketListener"/> class. /// </summary> - public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState> + /// <param name="logger">Instance of the <see cref="ILogger{SessionInfoWebSocketListener}"/> interface.</param> + /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> + public SessionInfoWebSocketListener(ILogger<SessionInfoWebSocketListener> logger, ISessionManager sessionManager) + : base(logger) + { + _sessionManager = sessionManager; + + _sessionManager.SessionStarted += OnSessionManagerSessionStarted; + _sessionManager.SessionEnded += OnSessionManagerSessionEnded; + _sessionManager.PlaybackStart += OnSessionManagerPlaybackStart; + _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped; + _sessionManager.PlaybackProgress += OnSessionManagerPlaybackProgress; + _sessionManager.CapabilitiesChanged += OnSessionManagerCapabilitiesChanged; + _sessionManager.SessionActivity += OnSessionManagerSessionActivity; + } + + /// <inheritdoc /> + protected override SessionMessageType Type => SessionMessageType.Sessions; + + /// <inheritdoc /> + protected override SessionMessageType StartType => SessionMessageType.SessionsStart; + + /// <inheritdoc /> + protected override SessionMessageType StopType => SessionMessageType.SessionsStop; + + /// <summary> + /// Gets the data to send. + /// </summary> + /// <returns>Task{SystemInfo}.</returns> + protected override Task<IEnumerable<SessionInfo>> GetDataToSend() + { + return Task.FromResult(_sessionManager.Sessions); + } + + /// <inheritdoc /> + protected override void Dispose(bool dispose) + { + _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; + _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; + _sessionManager.PlaybackStart -= OnSessionManagerPlaybackStart; + _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; + _sessionManager.PlaybackProgress -= OnSessionManagerPlaybackProgress; + _sessionManager.CapabilitiesChanged -= OnSessionManagerCapabilitiesChanged; + _sessionManager.SessionActivity -= OnSessionManagerSessionActivity; + + base.Dispose(dispose); + } + + private async void OnSessionManagerSessionActivity(object? sender, SessionEventArgs e) + { + await SendData(false).ConfigureAwait(false); + } + + private async void OnSessionManagerCapabilitiesChanged(object? sender, SessionEventArgs e) + { + await SendData(true).ConfigureAwait(false); + } + + private async void OnSessionManagerPlaybackProgress(object? sender, PlaybackProgressEventArgs e) + { + await SendData(!e.IsAutomated).ConfigureAwait(false); + } + + private async void OnSessionManagerPlaybackStopped(object? sender, PlaybackStopEventArgs e) + { + await SendData(true).ConfigureAwait(false); + } + + private async void OnSessionManagerPlaybackStart(object? sender, PlaybackProgressEventArgs e) + { + await SendData(true).ConfigureAwait(false); + } + + private async void OnSessionManagerSessionEnded(object? sender, SessionEventArgs e) + { + await SendData(true).ConfigureAwait(false); + } + + private async void OnSessionManagerSessionStarted(object? sender, SessionEventArgs e) { - private readonly ISessionManager _sessionManager; - - /// <summary> - /// Initializes a new instance of the <see cref="SessionInfoWebSocketListener"/> class. - /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger{SessionInfoWebSocketListener}"/> interface.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> - public SessionInfoWebSocketListener(ILogger<SessionInfoWebSocketListener> logger, ISessionManager sessionManager) - : base(logger) - { - _sessionManager = sessionManager; - - _sessionManager.SessionStarted += OnSessionManagerSessionStarted; - _sessionManager.SessionEnded += OnSessionManagerSessionEnded; - _sessionManager.PlaybackStart += OnSessionManagerPlaybackStart; - _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped; - _sessionManager.PlaybackProgress += OnSessionManagerPlaybackProgress; - _sessionManager.CapabilitiesChanged += OnSessionManagerCapabilitiesChanged; - _sessionManager.SessionActivity += OnSessionManagerSessionActivity; - } - - /// <inheritdoc /> - protected override SessionMessageType Type => SessionMessageType.Sessions; - - /// <inheritdoc /> - protected override SessionMessageType StartType => SessionMessageType.SessionsStart; - - /// <inheritdoc /> - protected override SessionMessageType StopType => SessionMessageType.SessionsStop; - - /// <summary> - /// Gets the data to send. - /// </summary> - /// <returns>Task{SystemInfo}.</returns> - protected override Task<IEnumerable<SessionInfo>> GetDataToSend() - { - return Task.FromResult(_sessionManager.Sessions); - } - - /// <inheritdoc /> - protected override void Dispose(bool dispose) - { - _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; - _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; - _sessionManager.PlaybackStart -= OnSessionManagerPlaybackStart; - _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; - _sessionManager.PlaybackProgress -= OnSessionManagerPlaybackProgress; - _sessionManager.CapabilitiesChanged -= OnSessionManagerCapabilitiesChanged; - _sessionManager.SessionActivity -= OnSessionManagerSessionActivity; - - base.Dispose(dispose); - } - - private async void OnSessionManagerSessionActivity(object? sender, SessionEventArgs e) - { - await SendData(false).ConfigureAwait(false); - } - - private async void OnSessionManagerCapabilitiesChanged(object? sender, SessionEventArgs e) - { - await SendData(true).ConfigureAwait(false); - } - - private async void OnSessionManagerPlaybackProgress(object? sender, PlaybackProgressEventArgs e) - { - await SendData(!e.IsAutomated).ConfigureAwait(false); - } - - private async void OnSessionManagerPlaybackStopped(object? sender, PlaybackStopEventArgs e) - { - await SendData(true).ConfigureAwait(false); - } - - private async void OnSessionManagerPlaybackStart(object? sender, PlaybackProgressEventArgs e) - { - await SendData(true).ConfigureAwait(false); - } - - private async void OnSessionManagerSessionEnded(object? sender, SessionEventArgs e) - { - await SendData(true).ConfigureAwait(false); - } - - private async void OnSessionManagerSessionStarted(object? sender, SessionEventArgs e) - { - await SendData(true).ConfigureAwait(false); - } + await SendData(true).ConfigureAwait(false); } } |
