diff options
Diffstat (limited to 'Jellyfin.Api')
87 files changed, 3744 insertions, 992 deletions
diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 27a1f61be0..c56233794a 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -18,6 +18,7 @@ namespace Jellyfin.Api.Auth public class CustomAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> { private readonly IAuthService _authService; + private readonly ILogger<CustomAuthenticationHandler> _logger; /// <summary> /// Initializes a new instance of the <see cref="CustomAuthenticationHandler" /> class. @@ -35,6 +36,7 @@ namespace Jellyfin.Api.Auth ISystemClock clock) : base(options, logger, encoder, clock) { _authService = authService; + _logger = logger.CreateLogger<CustomAuthenticationHandler>(); } /// <inheritdoc /> @@ -70,7 +72,8 @@ namespace Jellyfin.Api.Auth } catch (AuthenticationException ex) { - return Task.FromResult(AuthenticateResult.Fail(ex)); + _logger.LogDebug(ex, "Error authenticating with {Handler}", nameof(CustomAuthenticationHandler)); + return Task.FromResult(AuthenticateResult.NoResult()); } catch (SecurityException ex) { diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs new file mode 100644 index 0000000000..b5932ea6b4 --- /dev/null +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs @@ -0,0 +1,58 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy +{ + /// <summary> + /// Default authorization handler. + /// </summary> + public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement> + { + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayAccessHandler"/> class. + /// </summary> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> + /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param> + public SyncPlayAccessHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + _userManager = userManager; + } + + /// <inheritdoc /> + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement) + { + if (!ValidateClaims(context.User)) + { + context.Fail(); + return Task.CompletedTask; + } + + var userId = ClaimHelpers.GetUserId(context.User); + var user = _userManager.GetUserById(userId!.Value); + + if ((requirement.RequiredAccess.HasValue && user.SyncPlayAccess == requirement.RequiredAccess) + || user.SyncPlayAccess == SyncPlayAccess.CreateAndJoinGroups) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs new file mode 100644 index 0000000000..7fcaf69f6e --- /dev/null +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs @@ -0,0 +1,33 @@ +using Jellyfin.Data.Enums; +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy +{ + /// <summary> + /// The default authorization requirement. + /// </summary> + public class SyncPlayAccessRequirement : IAuthorizationRequirement + { + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class. + /// </summary> + /// <param name="requiredAccess">A value of <see cref="SyncPlayAccess"/>.</param> + public SyncPlayAccessRequirement(SyncPlayAccess requiredAccess) + { + RequiredAccess = requiredAccess; + } + + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class. + /// </summary> + public SyncPlayAccessRequirement() + { + RequiredAccess = null; + } + + /// <summary> + /// Gets the required SyncPlay access. + /// </summary> + public SyncPlayAccess? RequiredAccess { get; } + } +} diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index 7d77674700..b35ceea1a3 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -49,5 +49,15 @@ namespace Jellyfin.Api.Constants /// Policy name for escaping schedule controls or requiring first time setup. /// </summary> public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; + + /// <summary> + /// Policy name for requiring access to SyncPlay. + /// </summary> + public const string SyncPlayAccess = "SyncPlayAccess"; + + /// <summary> + /// Policy name for requiring group creation access to SyncPlay. + /// </summary> + public const string SyncPlayCreateGroupAccess = "SyncPlayCreateGroupAccess"; } } diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs index e8d6ccdf27..8c43d786a7 100644 --- a/Jellyfin.Api/Controllers/ApiKeyController.cs +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Globalization; using Jellyfin.Api.Constants; diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs index 9bad206e02..fed7ed3e5a 100644 --- a/Jellyfin.Api/Controllers/ArtistsController.cs +++ b/Jellyfin.Api/Controllers/ArtistsController.cs @@ -1,9 +1,8 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Dto; @@ -87,26 +86,26 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [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] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [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, @@ -119,64 +118,55 @@ namespace Jellyfin.Api.Controllers .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = null; - BaseItem parentItem; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); if (userId.HasValue && !userId.Equals(Guid.Empty)) { user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } - - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + GenreIds = genreIds, + StudioIds = studioIds, Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, EnableTotalRecordCount = enableTotalRecordCount }; - if (!string.IsNullOrWhiteSpace(parentId)) + if (parentId.HasValue) { if (parentItem is Folder) { - query.AncestorIds = new[] { new Guid(parentId) }; + query.AncestorIds = new[] { parentId.Value }; } else { - query.ItemIds = new[] { new Guid(parentId) }; + query.ItemIds = new[] { parentId.Value }; } } // Studios - if (!string.IsNullOrEmpty(studios)) + if (studios.Length != 0) { - query.StudioIds = studios.Split('|').Select(i => + query.StudioIds = studios.Select(i => { try { @@ -230,7 +220,7 @@ namespace Jellyfin.Api.Controllers var (baseItem, itemCounts) = i; var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - if (!string.IsNullOrWhiteSpace(includeItemTypes)) + if (includeItemTypes.Length != 0) { dto.ChildCount = itemCounts.ItemCount; dto.ProgramCount = itemCounts.ProgramCount; @@ -295,26 +285,26 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, - [FromQuery] string? genres, - [FromQuery] string? genreIds, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [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] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? studioIds, + [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, @@ -327,64 +317,55 @@ namespace Jellyfin.Api.Controllers .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = null; - BaseItem parentItem; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); if (userId.HasValue && !userId.Equals(Guid.Empty)) { user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); - } - - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, NameLessThan = nameLessThan, NameStartsWith = nameStartsWith, NameStartsWithOrGreater = nameStartsWithOrGreater, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + GenreIds = genreIds, + StudioIds = studioIds, Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, MinCommunityRating = minCommunityRating, DtoOptions = dtoOptions, SearchTerm = searchTerm, EnableTotalRecordCount = enableTotalRecordCount }; - if (!string.IsNullOrWhiteSpace(parentId)) + if (parentId.HasValue) { if (parentItem is Folder) { - query.AncestorIds = new[] { new Guid(parentId) }; + query.AncestorIds = new[] { parentId.Value }; } else { - query.ItemIds = new[] { new Guid(parentId) }; + query.ItemIds = new[] { parentId.Value }; } } // Studios - if (!string.IsNullOrEmpty(studios)) + if (studios.Length != 0) { - query.StudioIds = studios.Split('|').Select(i => + query.StudioIds = studios.Select(i => { try { @@ -438,7 +419,7 @@ namespace Jellyfin.Api.Controllers var (baseItem, itemCounts) = i; var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user); - if (!string.IsNullOrWhiteSpace(includeItemTypes)) + if (includeItemTypes.Length != 0) { dto.ChildCount = itemCounts.ItemCount; dto.ProgramCount = itemCounts.ProgramCount; diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index d4c6e4af94..616fe5b91f 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; @@ -42,7 +42,7 @@ namespace Jellyfin.Api.Controllers /// <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 lenght.</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> @@ -71,29 +71,192 @@ namespace Jellyfin.Api.Controllers /// <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="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="transcodingReasons">Optional. The transcoding reason.</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:required}", Name = "GetAudioStreamByContainer")] [HttpGet("{itemId}/stream", Name = "GetAudioStream")] - [HttpHead("{itemId}/stream.{container:required}", Name = "HeadAudioStreamByContainer")] [HttpHead("{itemId}/stream", Name = "HeadAudioStream")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesAudioFile] public async Task<ActionResult> GetAudioStream( [FromRoute, Required] Guid itemId, - [FromRoute] string? container, + [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 + { + Id = itemId, + Container = container, + Static = @static ?? true, + 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 ?? true, + StartTimeTicks = startTimeTicks, + Width = width, + Height = height, + VideoBitRate = videoBitRate, + SubtitleStreamIndex = subtitleStreamIndex, + SubtitleMethod = subtitleMethod, + MaxRefFrames = maxRefFrames, + MaxVideoBitDepth = maxVideoBitDepth, + RequireAvc = requireAvc ?? true, + DeInterlace = deInterlace ?? true, + RequireNonAnamorphic = requireNonAnamorphic ?? true, + TranscodingMaxAudioChannels = transcodingMaxAudioChannels, + CpuCoreLimit = cpuCoreLimit, + LiveStreamId = liveStreamId, + EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, + VideoCodec = videoCodec, + SubtitleCodec = subtitleCodec, + TranscodeReasons = transcodeReasons, + AudioStreamIndex = audioStreamIndex, + VideoStreamIndex = videoStreamIndex, + Context = context ?? EncodingContext.Static, + StreamOptions = streamOptions + }; + + 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 lenght.</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, 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("{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, @@ -136,7 +299,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, @@ -188,7 +351,7 @@ namespace Jellyfin.Api.Controllers EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Static, diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs index 1d4836f278..d3ea412015 100644 --- a/Jellyfin.Api/Controllers/BrandingController.cs +++ b/Jellyfin.Api/Controllers/BrandingController.cs @@ -1,4 +1,4 @@ -using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Branding; using Microsoft.AspNetCore.Http; diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs index ec9d7cdce1..b70c76e807 100644 --- a/Jellyfin.Api/Controllers/ChannelsController.cs +++ b/Jellyfin.Api/Controllers/ChannelsController.cs @@ -92,7 +92,7 @@ namespace Jellyfin.Api.Controllers /// <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] string channelId) + public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId) { return _channelManager.GetChannelFeatures(channelId); } @@ -198,7 +198,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? channelIds) + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds) { var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) @@ -208,11 +208,7 @@ namespace Jellyfin.Api.Controllers { Limit = limit, StartIndex = startIndex, - ChannelIds = (channelIds ?? string.Empty) - .Split(',') - .Where(i => !string.IsNullOrWhiteSpace(i)) - .Select(i => new Guid(i)) - .ToArray(), + ChannelIds = channelIds, DtoOptions = new DtoOptions { Fields = fields } }; diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs index eae06b767c..2a7b2b5c62 100644 --- a/Jellyfin.Api/Controllers/CollectionController.cs +++ b/Jellyfin.Api/Controllers/CollectionController.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Net; @@ -54,7 +55,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<CollectionCreationResult>> CreateCollection( [FromQuery] string? name, - [FromQuery] string? ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids, [FromQuery] Guid? parentId, [FromQuery] bool isLocked = false) { @@ -65,7 +66,7 @@ namespace Jellyfin.Api.Controllers IsLocked = isLocked, Name = name, ParentId = parentId, - ItemIdList = RequestHelpers.Split(ids, ',', true), + ItemIdList = ids, UserIds = new[] { userId } }).ConfigureAwait(false); @@ -88,9 +89,11 @@ namespace Jellyfin.Api.Controllers /// <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] string ids) + public async Task<ActionResult> AddToCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(true); + await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true); return NoContent(); } @@ -103,9 +106,11 @@ namespace Jellyfin.Api.Controllers /// <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] string ids) + public async Task<ActionResult> RemoveFromCollection( + [FromRoute, Required] Guid collectionId, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(false); + await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false); return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index a859ac114c..ccc81dfc55 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 76f5717e30..8b8f63015e 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -6,6 +6,7 @@ using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; +using MediaBrowser.Common.Extensions; using MediaBrowser.Controller; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authorization; @@ -47,13 +48,19 @@ namespace Jellyfin.Api.Controllers [FromQuery, Required] Guid userId, [FromQuery, Required] string client) { - var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client); - var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.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 dto = new DisplayPreferencesDto { Client = displayPreferences.Client, - Id = displayPreferences.UserId.ToString(), + Id = displayPreferences.ItemId.ToString(), ViewType = itemPreferences.ViewType.ToString(), SortBy = itemPreferences.SortBy, SortOrder = itemPreferences.SortOrder, @@ -81,6 +88,16 @@ namespace Jellyfin.Api.Controllers dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture); dto.CustomPrefs["tvhome"] = displayPreferences.TvHome; + // Load all custom display preferences + var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); + if (customDisplayPreferences != null) + { + 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(); @@ -115,7 +132,12 @@ namespace Jellyfin.Api.Controllers HomeSectionType.LatestMedia, HomeSectionType.None, }; - var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client); + if (!Guid.TryParse(displayPreferencesId, out var itemId)) + { + itemId = displayPreferencesId.GetMD5(); + } + + var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null; existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; @@ -124,21 +146,33 @@ namespace Jellyfin.Api.Controllers existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion) ? Enum.Parse<ChromecastVersion>(chromecastVersion, true) : ChromecastVersion.Stable; + displayPreferences.CustomPrefs.Remove("chromecastVersion"); + existingDisplayPreferences.EnableNextVideoInfoOverlay = displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay) ? bool.Parse(enableNextVideoInfoOverlay) : true; + displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay"); + existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength) ? int.Parse(skipBackLength, CultureInfo.InvariantCulture) : 10000; + displayPreferences.CustomPrefs.Remove("skipBackLength"); + existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var 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))) @@ -149,26 +183,34 @@ namespace Jellyfin.Api.Controllers type = order < 7 ? defaults[order] : HomeSectionType.None; } + 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))) { - var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Parse(key.Substring("landing-".Length)), existingDisplayPreferences.Client); - itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType); + if (Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var preferenceId)) + { + var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, preferenceId, existingDisplayPreferences.Client); + itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType); + displayPreferences.CustomPrefs.Remove(key); + } } - var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Empty, existingDisplayPreferences.Client); + var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client); itemPrefs.SortBy = displayPreferences.SortBy; itemPrefs.SortOrder = displayPreferences.SortOrder; itemPrefs.RememberIndexing = displayPreferences.RememberIndexing; itemPrefs.RememberSorting = displayPreferences.RememberSorting; + itemPrefs.ItemId = itemId; if (Enum.TryParse<ViewType>(displayPreferences.ViewType, true, out var viewType)) { itemPrefs.ViewType = viewType; } + // Set all remaining custom preferences. + _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); _displayPreferencesManager.SaveChanges(); return NoContent(); diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 4e6455eaa7..4fd9c2fbf6 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -252,7 +252,7 @@ namespace Jellyfin.Api.Controllers private string GetAbsoluteUri() { - return $"{Request.Scheme}://{Request.Host}{Request.Path}"; + return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}"; } private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service) diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 6e59da798c..6e85737d26 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -41,6 +42,9 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] public class DynamicHlsController : BaseJellyfinApiController { + private const string DefaultEncoderPreset = "veryfast"; + private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; + private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IDlnaManager _dlnaManager; @@ -56,8 +60,7 @@ namespace Jellyfin.Api.Controllers private readonly ILogger<DynamicHlsController> _logger; private readonly EncodingHelper _encodingHelper; private readonly DynamicHlsHelper _dynamicHlsHelper; - - private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls; + private readonly EncodingOptions _encodingOptions; /// <summary> /// Initializes a new instance of the <see cref="DynamicHlsController"/> class. @@ -92,6 +95,8 @@ namespace Jellyfin.Api.Controllers ILogger<DynamicHlsController> logger, DynamicHlsHelper dynamicHlsHelper) { + _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); + _libraryManager = libraryManager; _userManager = userManager; _dlnaManager = dlnaManager; @@ -106,8 +111,7 @@ namespace Jellyfin.Api.Controllers _transcodingJobHelper = transcodingJobHelper; _logger = logger; _dynamicHlsHelper = dynamicHlsHelper; - - _encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + _encodingOptions = serverConfigurationManager.GetEncodingOptions(); } /// <summary> @@ -120,7 +124,7 @@ namespace Jellyfin.Api.Controllers /// <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 lenght.</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> @@ -149,14 +153,14 @@ namespace Jellyfin.Api.Controllers /// <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="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="transcodingReasons">Optional. The transcoding reason.</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> @@ -212,7 +216,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext context, @@ -264,7 +268,7 @@ namespace Jellyfin.Api.Controllers EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context, @@ -272,7 +276,7 @@ namespace Jellyfin.Api.Controllers EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming }; - return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); } /// <summary> @@ -285,7 +289,7 @@ namespace Jellyfin.Api.Controllers /// <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 lenght.</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> @@ -315,14 +319,14 @@ namespace Jellyfin.Api.Controllers /// <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="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="transcodingReasons">Optional. The transcoding reason.</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> @@ -379,7 +383,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext context, @@ -431,7 +435,7 @@ namespace Jellyfin.Api.Controllers EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context, @@ -439,7 +443,7 @@ namespace Jellyfin.Api.Controllers EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming }; - return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); + return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); } /// <summary> @@ -452,7 +456,7 @@ namespace Jellyfin.Api.Controllers /// <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 lenght.</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> @@ -481,14 +485,14 @@ namespace Jellyfin.Api.Controllers /// <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="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="transcodingReasons">Optional. The transcoding reason.</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> @@ -542,7 +546,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext context, @@ -594,7 +598,7 @@ namespace Jellyfin.Api.Controllers EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context, @@ -615,7 +619,7 @@ namespace Jellyfin.Api.Controllers /// <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 lenght.</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> @@ -645,14 +649,14 @@ namespace Jellyfin.Api.Controllers /// <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="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="transcodingReasons">Optional. The transcoding reason.</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> @@ -707,7 +711,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext context, @@ -759,7 +763,7 @@ namespace Jellyfin.Api.Controllers EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context, @@ -812,14 +816,14 @@ namespace Jellyfin.Api.Controllers /// <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="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="transcodingReasons">Optional. The transcoding reason.</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> @@ -834,7 +838,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid itemId, [FromRoute, Required] string playlistId, [FromRoute, Required] int segmentId, - [FromRoute] string container, + [FromRoute, Required] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, @@ -877,7 +881,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext context, @@ -929,7 +933,7 @@ namespace Jellyfin.Api.Controllers EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context, @@ -953,7 +957,7 @@ namespace Jellyfin.Api.Controllers /// <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 lenght.</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> @@ -983,14 +987,14 @@ namespace Jellyfin.Api.Controllers /// <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="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="transcodingReasons">Optional. The transcoding reason.</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> @@ -1005,7 +1009,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid itemId, [FromRoute, Required] string playlistId, [FromRoute, Required] int segmentId, - [FromRoute] string container, + [FromRoute, Required] string container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, @@ -1049,7 +1053,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext context, @@ -1101,7 +1105,7 @@ namespace Jellyfin.Api.Controllers EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context, @@ -1129,7 +1133,7 @@ namespace Jellyfin.Api.Controllers _dlnaManager, _deviceManager, _transcodingJobHelper, - _transcodingJobType, + TranscodingJobType, cancellationTokenSource.Token) .ConfigureAwait(false); @@ -1137,11 +1141,19 @@ namespace Jellyfin.Api.Controllers var segmentLengths = GetSegmentLengths(state); + var segmentContainer = state.Request.SegmentContainer ?? "ts"; + + // http://ffmpeg.org/ffmpeg-all.html#toc-hls-2 + var isHlsInFmp4 = string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase); + var hlsVersion = isHlsInFmp4 ? "7" : "3"; + var builder = new StringBuilder(); builder.AppendLine("#EXTM3U") .AppendLine("#EXT-X-PLAYLIST-TYPE:VOD") - .AppendLine("#EXT-X-VERSION:3") + .Append("#EXT-X-VERSION:") + .Append(hlsVersion) + .AppendLine() .Append("#EXT-X-TARGETDURATION:") .Append(Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength)) .AppendLine() @@ -1151,6 +1163,18 @@ namespace Jellyfin.Api.Controllers var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer); var queryString = Request.QueryString; + if (isHlsInFmp4) + { + builder.Append("#EXT-X-MAP:URI=\"") + .Append("hls1/") + .Append(name) + .Append("/-1") + .Append(segmentExtension) + .Append(queryString) + .Append('"') + .AppendLine(); + } + foreach (var length in segmentLengths) { builder.Append("#EXTINF:") @@ -1194,7 +1218,7 @@ namespace Jellyfin.Api.Controllers _dlnaManager, _deviceManager, _transcodingJobHelper, - _transcodingJobType, + TranscodingJobType, cancellationTokenSource.Token) .ConfigureAwait(false); @@ -1208,7 +1232,7 @@ namespace Jellyfin.Api.Controllers if (System.IO.File.Exists(segmentPath)) { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + 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); } @@ -1222,7 +1246,7 @@ namespace Jellyfin.Api.Controllers { if (System.IO.File.Exists(segmentPath)) { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); transcodingLock.Release(); released = true; _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); @@ -1233,7 +1257,13 @@ namespace Jellyfin.Api.Controllers var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - if (currentTranscodingIndex == null) + if (segmentId == -1) + { + _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); + startTranscoding = true; + segmentId = 0; + } + else if (currentTranscodingIndex == null) { _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); startTranscoding = true; @@ -1265,13 +1295,12 @@ namespace Jellyfin.Api.Controllers streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId); state.WaitForPath = segmentPath; - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); job = await _transcodingJobHelper.StartFfMpeg( state, playlistPath, - GetCommandLineArguments(playlistPath, encodingOptions, state, true, segmentId), + GetCommandLineArguments(playlistPath, state, true, segmentId), Request, - _transcodingJobType, + TranscodingJobType, cancellationTokenSource).ConfigureAwait(false); } catch @@ -1284,7 +1313,7 @@ namespace Jellyfin.Api.Controllers } else { - job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); if (job?.TranscodingThrottler != null) { await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); @@ -1301,7 +1330,7 @@ namespace Jellyfin.Api.Controllers } _logger.LogDebug("returning {0} [general case]", segmentPath); - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } @@ -1325,11 +1354,10 @@ namespace Jellyfin.Api.Controllers return result.ToArray(); } - private string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding, int startNumber) + private string GetCommandLineArguments(string outputPath, StreamState state, bool isEncoding, int startNumber) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions); - - var threads = _encodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec); + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); if (state.BaseRequest.BreakOnNonKeyFrames) { @@ -1341,36 +1369,57 @@ namespace Jellyfin.Api.Controllers state.BaseRequest.BreakOnNonKeyFrames = false; } - var inputModifier = _encodingHelper.GetInputModifier(state, encodingOptions); - // If isEncoding is true we're actually starting ffmpeg var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0"; - + var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions); var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; 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 = GetSegmentFileExtension(state.Request.SegmentContainer); + var outputTsArg = outputPrefix + "%d" + outputExtension; - var outputTsArg = Path.Combine(directory, Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request.SegmentContainer); - - var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.'); + var segmentFormat = outputExtension.TrimStart('.'); if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) { segmentFormat = "mpegts"; } + else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var outputFmp4HeaderArg = string.Empty; + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + if (isWindows) + { + // on Windows, the path of fmp4 header file needs to be configured + outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\""; + } + else + { + // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder + outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""; + } + + segmentFormat = "fmp4" + outputFmp4HeaderArg; + } + else + { + _logger.LogError("Invalid HLS segment container: " + segmentFormat); + } - var maxMuxingQueueSize = encodingOptions.MaxMuxingQueueSize > 128 - ? encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) + var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 + ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) : "128"; 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} -individual_header_trailer 0 -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"", + "{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} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"", inputModifier, - _encodingHelper.GetInputArgument(state, encodingOptions), + _encodingHelper.GetInputArgument(state, _encodingOptions), threads, mapArgs, - GetVideoArguments(state, encodingOptions, startNumber), - GetAudioArguments(state, encodingOptions), + GetVideoArguments(state, startNumber), + GetAudioArguments(state), maxMuxingQueueSize, state.SegmentLength.ToString(CultureInfo.InvariantCulture), segmentFormat, @@ -1379,50 +1428,63 @@ namespace Jellyfin.Api.Controllers outputPath).Trim(); } - private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions) + /// <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 == null) + { + return string.Empty; + } + var audioCodec = _encodingHelper.GetAudioEncoder(state); if (!state.IsOutputVideo) { if (EncodingHelper.IsCopyCodec(audioCodec)) { - return "-acodec copy"; + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + + return "-acodec copy -strict -2" + bitStreamArgs; } - var audioTranscodeParams = new List<string>(); + var audioTranscodeParams = string.Empty; - audioTranscodeParams.Add("-acodec " + audioCodec); + audioTranscodeParams += "-acodec " + audioCodec; if (state.OutputAudioBitrate.HasValue) { - audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture)); + audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); } if (state.OutputAudioChannels.HasValue) { - audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture)); + audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); } if (state.OutputAudioSampleRate.HasValue) { - audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture)); + audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - audioTranscodeParams.Add("-vn"); - return string.Join(' ', audioTranscodeParams); + audioTranscodeParams += " -vn"; + return audioTranscodeParams; } if (EncodingHelper.IsCopyCodec(audioCodec)) { - var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions); + var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) { - return "-codec:a:0 copy -copypriorss:a:0 0"; + return "-codec:a:0 copy -strict -2 -copypriorss:a:0 0" + bitStreamArgs; } - return "-codec:a:0 copy"; + return "-codec:a:0 copy -strict -2" + bitStreamArgs; } var args = "-codec:a:0 " + audioCodec; @@ -1446,94 +1508,89 @@ namespace Jellyfin.Api.Controllers args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true); + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); return args; } - private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber) + /// <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> + /// <returns>The command line arguments for video transcoding.</returns> + private string GetVideoArguments(StreamState state, int startNumber) { + if (state.VideoStream == null) + { + return string.Empty; + } + if (!state.IsOutputVideo) { return string.Empty; } - var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions); + var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); var args = "-codec:v:0 " + codec; + // Prefer hvc1 to hev1. + 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)) + { + args += " -tag:v:0 hvc1"; + } + // if (state.EnableMpegtsM2TsMode) // { // args += " -mpegts_m2ts_mode 1"; // } - // See if we can save come cpu cycles by avoiding encoding + // See if we can save come cpu cycles by avoiding encoding. if (EncodingHelper.IsCopyCodec(codec)) { if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) { - string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream); + string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); if (!string.IsNullOrEmpty(bitStreamArgs)) { args += " " + bitStreamArgs; } } + args += " -start_at_zero"; + // args += " -flags -global_header"; } else { - var gopArg = string.Empty; - var keyFrameArg = string.Format( - CultureInfo.InvariantCulture, - " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"", - startNumber * state.SegmentLength, - state.SegmentLength); + args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset); - var framerate = state.VideoStream?.RealFrameRate; + // Set the key frame params for video encoding to match the hls segment time. + args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, false, startNumber); - if (framerate.HasValue) + // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. + if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) { - // This is to make sure keyframe interval is limited to our segment, - // as forcing keyframes is not enough. - // Example: we encoded half of desired length, then codec detected - // scene cut and inserted a keyframe; next forced keyframe would - // be created outside of segment, which breaks seeking - // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe - gopArg = string.Format( - CultureInfo.InvariantCulture, - " -g {0} -keyint_min {0} -sc_threshold 0", - Math.Ceiling(state.SegmentLength * framerate.Value)); - } - - args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast"); - - // Unable to force key frames using these hw encoders, set key frames by GOP - if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)) - { - args += " " + gopArg; - } - else - { - args += " " + keyFrameArg + gopArg; + args += " -bf 0"; } // args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; - // This is for graphical subs if (hasGraphicalSubs) { - args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec); + // Graphical subs overlay and resolution params. + args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); } - - // Add resolution params, if specified else { - args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec); + // Resolution params. + args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); } // -start_at_zero is necessary to use with -ss when seeking, @@ -1693,7 +1750,7 @@ namespace Jellyfin.Api.Controllers private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) { - var job = _transcodingJobHelper.GetTranscodingJob(playlist, _transcodingJobType); + var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType); if (job == null || job.HasExited) { diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index 008bb58d16..9220b988fa 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Linq; using Jellyfin.Api.Constants; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -49,37 +50,29 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy( [FromQuery] Guid? userId, - [FromQuery] string? parentId, - [FromQuery] string? includeItemTypes, - [FromQuery] string? mediaTypes) + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes) { - var parentItem = string.IsNullOrEmpty(parentId) - ? null - : _libraryManager.GetItemById(parentId); - var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) + BaseItem? item = null; + if (includeItemTypes.Length != 1 + || !(string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase))) { - parentItem = null; + item = _libraryManager.GetParentItem(parentId, user?.Id); } - var item = string.IsNullOrEmpty(parentId) - ? user == null - ? _libraryManager.RootFolder - : _libraryManager.GetUserRootFolder() - : parentItem; - var query = new InternalItemsQuery { User = user, - MediaTypes = (mediaTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries), - IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries), + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, Recursive = true, EnableTotalRecordCount = false, DtoOptions = new DtoOptions @@ -90,7 +83,12 @@ namespace Jellyfin.Api.Controllers } }; - var itemList = ((Folder)item!).GetItemList(query); + if (item is not Folder folder) + { + return new QueryFiltersLegacy(); + } + + var itemList = folder.GetItemList(query); return new QueryFiltersLegacy { Years = itemList.Select(i => i.ProductionYear ?? -1) @@ -138,8 +136,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryFilters> GetQueryFilters( [FromQuery] Guid? userId, - [FromQuery] string? parentId, - [FromQuery] string? includeItemTypes, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool? isAiring, [FromQuery] bool? isMovie, [FromQuery] bool? isSports, @@ -148,27 +146,28 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? isSeries, [FromQuery] bool? recursive) { - var parentItem = string.IsNullOrEmpty(parentId) - ? null - : _libraryManager.GetItemById(parentId); - var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; - if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase)) + BaseItem? parentItem = null; + if (includeItemTypes.Length == 1 + && (string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase))) { parentItem = null; } + else if (parentId.HasValue) + { + parentItem = _libraryManager.GetItemById(parentId.Value); + } var filters = new QueryFilters(); var genreQuery = new InternalItemsQuery(user) { - IncludeItemTypes = - (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries), + IncludeItemTypes = includeItemTypes, DtoOptions = new DtoOptions { Fields = Array.Empty<ItemFields>(), @@ -192,10 +191,11 @@ namespace Jellyfin.Api.Controllers genreQuery.Parent = parentItem; } - if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase)) + if (includeItemTypes.Length == 1 + && (string.Equals(includeItemTypes[0], nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(MusicVideo), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(MusicArtist), StringComparison.OrdinalIgnoreCase) + || string.Equals(includeItemTypes[0], nameof(Audio), StringComparison.OrdinalIgnoreCase))) { filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair { diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs index 9c222135d0..b6755ed5e5 100644 --- a/Jellyfin.Api/Controllers/GenresController.cs +++ b/Jellyfin.Api/Controllers/GenresController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Linq; using Jellyfin.Api.Constants; @@ -72,10 +72,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool? isFavorite, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, @@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, @@ -109,15 +109,15 @@ namespace Jellyfin.Api.Controllers EnableTotalRecordCount = enableTotalRecordCount }; - if (!string.IsNullOrWhiteSpace(parentId)) + if (parentId.HasValue) { if (parentItem is Folder) { - query.AncestorIds = new[] { new Guid(parentId) }; + query.AncestorIds = new[] { parentId.Value }; } else { - query.ItemIds = new[] { new Guid(parentId) }; + query.ItemIds = new[] { parentId.Value }; } } @@ -133,7 +133,7 @@ namespace Jellyfin.Api.Controllers result = _libraryManager.GetGenres(query); } - var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes); + var shouldIncludeItemTypes = includeItemTypes.Length != 0; return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index 3b75e8d431..f519877326 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.IO; @@ -112,11 +112,13 @@ namespace Jellyfin.Api.Controllers /// <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( @@ -132,13 +134,25 @@ namespace Jellyfin.Api.Controllers var normalizedPlaylistId = playlistId; - var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath) - .FirstOrDefault(i => - string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase) - && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1) - ?? throw new ResourceNotFoundException($"Provided path ({transcodeFolderPath}) is not valid."); + 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 GetFileResult(file, playlistPath); + return playlistPath == null + ? NotFound("Hls segment not found.") + : GetFileResult(file, playlistPath); } private ActionResult GetFileResult(string path, string playlistPath) diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 76e53b9a53..65de81d7a6 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; @@ -86,7 +86,6 @@ namespace Jellyfin.Api.Controllers /// <response code="403">User does not have permission to delete the image.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Users/{userId}/Images/{imageType}")] - [HttpPost("Users/{userId}/Images/{imageType}/{index?}", Name = "PostUserImage_2")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status403Forbidden)] @@ -95,7 +94,53 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult> PostUserImage( [FromRoute, Required] Guid userId, [FromRoute, Required] ImageType imageType, - [FromRoute] int? index = null) + [FromQuery] int? index = null) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + { + return Forbid("User is not allowed to update the image."); + } + + var user = _userManager.GetUserById(userId); + await using var memoryStream = await GetMemoryStream(Request.Body).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 != null) + { + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + } + + user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType))); + + await _providerManager + .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) + .ConfigureAwait(false); + await _userManager.UpdateUserAsync(user).ConfigureAwait(false); + + 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)] + [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(_authContext, HttpContext.Request, userId, true)) { @@ -132,8 +177,7 @@ namespace Jellyfin.Api.Controllers /// <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/{itemType}")] - [HttpDelete("Users/{userId}/Images/{itemType}/{index?}", Name = "DeleteUserImage_2")] + [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")] @@ -142,7 +186,46 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult> DeleteUserImage( [FromRoute, Required] Guid userId, [FromRoute, Required] ImageType imageType, - [FromRoute] int? index = null) + [FromQuery] int? index = null) + { + if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true)) + { + return Forbid("User is not allowed to delete the image."); + } + + var user = _userManager.GetUserById(userId); + try + { + System.IO.File.Delete(user.ProfileImage.Path); + } + catch (IOException e) + { + _logger.LogError(e, "Error deleting user profile image:"); + } + + await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); + 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(_authContext, HttpContext.Request, userId, true)) { @@ -173,14 +256,13 @@ namespace Jellyfin.Api.Controllers /// <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}")] - [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "DeleteItemImage_2")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> DeleteItemImage( [FromRoute, Required] Guid itemId, [FromRoute, Required] ImageType imageType, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) { var item = _libraryManager.GetItemById(itemId); if (item == null) @@ -193,24 +275,82 @@ namespace Jellyfin.Api.Controllers } /// <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 == null) + { + return NotFound(); + } + + 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> - /// <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}")] - [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "SetItemImage_2")] [Authorize(Policy = Policies.RequiresElevation)] [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 == null) + { + return NotFound(); + } + + // Handle image/png; charset=utf-8 + var mimeType = Request.ContentType.Split(';').FirstOrDefault(); + await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); + + 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)] + [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 = null) + [FromRoute] int imageIndex) { var item = _libraryManager.GetItemById(itemId); if (item == null) @@ -350,8 +490,6 @@ namespace Jellyfin.Api.Controllers /// </returns> [HttpGet("Items/{itemId}/Images/{imageType}")] [HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")] - [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "GetItemImage_2")] - [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] @@ -372,7 +510,86 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? blur, [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetItemById(itemId); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + itemId, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .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="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="addPlayedIndicator">Optional. Add a played indicator.</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] string? tag, + [FromQuery] bool? cropWhitespace, + [FromQuery] ImageFormat? format, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] double? percentPlayed, + [FromQuery] int? unplayedCount, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) { var item = _libraryManager.GetItemById(itemId); if (item == null) @@ -508,8 +725,8 @@ namespace Jellyfin.Api.Controllers /// 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")] + [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")] + [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] @@ -587,8 +804,8 @@ namespace Jellyfin.Api.Controllers /// 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 = "HeadGenreImage")] + [HttpGet("Genres/{name}/Images/{imageType}")] + [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] @@ -609,7 +826,86 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? blur, [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetGenre(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .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="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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) { var item = _libraryManager.GetGenre(name); if (item == null) @@ -666,8 +962,8 @@ namespace Jellyfin.Api.Controllers /// 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 = "HeadMusicGenreImage")] + [HttpGet("MusicGenres/{name}/Images/{imageType}")] + [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] @@ -688,7 +984,86 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? blur, [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetMusicGenre(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .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="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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) { var item = _libraryManager.GetMusicGenre(name); if (item == null) @@ -745,8 +1120,8 @@ namespace Jellyfin.Api.Controllers /// 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 = "HeadPersonImage")] + [HttpGet("Persons/{name}/Images/{imageType}")] + [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] @@ -767,7 +1142,86 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? blur, [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetPerson(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .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="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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) { var item = _libraryManager.GetPerson(name); if (item == null) @@ -824,16 +1278,16 @@ namespace Jellyfin.Api.Controllers /// 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 = "HeadStudioImage")] + [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, - [FromRoute, Required] string tag, - [FromRoute, Required] ImageFormat format, + [FromQuery] string? tag, + [FromQuery] ImageFormat? format, [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] double? percentPlayed, @@ -846,7 +1300,86 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? blur, [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) + { + var item = _libraryManager.GetStudio(name); + if (item == null) + { + return NotFound(); + } + + return await GetImageInternal( + item.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + item, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase)) + .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="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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) { var item = _libraryManager.GetStudio(name); if (item == null) @@ -903,8 +1436,8 @@ namespace Jellyfin.Api.Controllers /// 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 = "HeadUserImage")] + [HttpGet("Users/{userId}/Images/{imageType}")] + [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesImageFile] @@ -925,7 +1458,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? blur, [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer, - [FromRoute] int? imageIndex = null) + [FromQuery] int? imageIndex) { var user = _userManager.GetUserById(userId); if (user == null) @@ -974,6 +1507,103 @@ namespace Jellyfin.Api.Controllers .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="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="addPlayedIndicator">Optional. Add a played indicator.</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] bool? cropWhitespace, + [FromQuery] bool? addPlayedIndicator, + [FromQuery] int? blur, + [FromQuery] string? backgroundColor, + [FromQuery] string? foregroundLayer) + { + var user = _userManager.GetUserById(userId); + if (user?.ProfileImage == null) + { + return NotFound(); + } + + var info = new ItemImageInfo + { + Path = user.ProfileImage.Path, + Type = ImageType.Profile, + DateModified = user.ProfileImage.LastModified + }; + + if (width.HasValue) + { + info.Width = width.Value; + } + + if (height.HasValue) + { + info.Height = height.Value; + } + + return await GetImageInternal( + user.Id, + imageType, + imageIndex, + tag, + format, + maxWidth, + maxHeight, + percentPlayed, + unplayedCount, + width, + height, + quality, + cropWhitespace, + addPlayedIndicator, + blur, + backgroundColor, + foregroundLayer, + null, + Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase), + info) + .ConfigureAwait(false); + } + private static async Task<MemoryStream> GetMemoryStream(Stream inputStream) { using var reader = new StreamReader(inputStream); diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index d17a26db43..2446257528 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -206,7 +206,7 @@ namespace Jellyfin.Api.Controllers /// <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")] + [HttpGet("Artists/{id}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( [FromRoute, Required] Guid id, @@ -242,7 +242,7 @@ namespace Jellyfin.Api.Controllers /// <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")] + [HttpGet("MusicGenres/{id}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres( [FromRoute, Required] Guid id, diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index a7c1a63882..6c38f77ce1 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 0a6ed31ae3..9e1a398538 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index d8d371ebc3..7e9035f80f 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; @@ -60,7 +60,6 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets items based on a query. /// </summary> - /// <param name="uId">The user id supplied in the /Users/{uid}/Items.</param> /// <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> @@ -73,8 +72,8 @@ namespace Jellyfin.Api.Controllers /// <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 delimeted.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</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> @@ -87,42 +86,42 @@ namespace Jellyfin.Api.Controllers /// <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="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</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 delimeted. 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 delimeted.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</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 delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</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 delimeted.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</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 delimeted.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</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 delimeted.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</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 delimeted.</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> @@ -133,20 +132,18 @@ namespace Jellyfin.Api.Controllers /// <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 delimeted.</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 delimeted.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</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")] - [HttpGet("Users/{uId}/Items", Name = "GetItems_2")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetItems( - [FromRoute] Guid? uId, [FromQuery] Guid? userId, [FromQuery] string? maxOfficialRating, [FromQuery] bool? hasThemeSong, @@ -159,7 +156,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, [FromQuery] bool? is4K, - [FromQuery] string? locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, @@ -173,42 +170,42 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasImdbId, [FromQuery] bool? hasTmdbId, [FromQuery] bool? hasTvdbId, - [FromQuery] string? excludeItemIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? recursive, [FromQuery] string? searchTerm, [FromQuery] string? sortOrder, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, [FromQuery] string? sortBy, [FromQuery] bool? isPlayed, - [FromQuery] string? genres, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [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] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? artists, - [FromQuery] string? excludeArtistIds, - [FromQuery] string? artistIds, - [FromQuery] string? albumArtistIds, - [FromQuery] string? contributingArtistIds, - [FromQuery] string? albums, - [FromQuery] string? albumIds, - [FromQuery] string? ids, - [FromQuery] string? videoTypes, + [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, @@ -219,18 +216,15 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] bool? is3D, - [FromQuery] string? seriesStatus, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery] string? studioIds, - [FromQuery] string? genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { - // use user id route parameter over query parameter - userId = uId ?? userId; - var user = userId.HasValue && !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId.Value) : null; @@ -238,20 +232,15 @@ namespace Jellyfin.Api.Controllers .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase) - || string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase)) + if (includeItemTypes.Length == 1 + && (includeItemTypes[0].Equals("Playlist", StringComparison.OrdinalIgnoreCase) + || includeItemTypes[0].Equals("BoxSet", StringComparison.OrdinalIgnoreCase))) { parentId = null; } - BaseItem? item = null; + var item = _libraryManager.GetParentItem(parentId, userId); QueryResult<BaseItem> result; - if (!string.IsNullOrEmpty(parentId)) - { - item = _libraryManager.GetItemById(parentId); - } - - item ??= _libraryManager.GetUserRootFolder(); if (!(item is Folder folder)) { @@ -262,7 +251,7 @@ namespace Jellyfin.Api.Controllers && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase)) { recursive = true; - includeItemTypes = "Playlist"; + includeItemTypes = new[] { "Playlist" }; } bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id) @@ -291,14 +280,14 @@ namespace Jellyfin.Api.Controllers return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); } - if ((recursive.HasValue && recursive.Value) || !string.IsNullOrEmpty(ids) || !(item is UserRootFolder)) + if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || !(item is UserRootFolder)) { var query = new InternalItemsQuery(user!) { IsPlayed = isPlayed, - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + MediaTypes = mediaTypes, + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, Recursive = recursive ?? false, OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), IsFavorite = isFavorite, @@ -330,28 +319,28 @@ namespace Jellyfin.Api.Controllers HasTrailer = hasTrailer, IsHD = isHd, Is4K = is4K, - Tags = RequestHelpers.Split(tags, '|', true), - OfficialRatings = RequestHelpers.Split(officialRatings, '|', true), - Genres = RequestHelpers.Split(genres, '|', true), - ArtistIds = RequestHelpers.GetGuids(artistIds), - AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds), - ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds), - GenreIds = RequestHelpers.GetGuids(genreIds), - StudioIds = RequestHelpers.GetGuids(studioIds), + Tags = tags, + OfficialRatings = officialRatings, + Genres = genres, + ArtistIds = artistIds, + AlbumArtistIds = albumArtistIds, + ContributingArtistIds = contributingArtistIds, + GenreIds = genreIds, + StudioIds = studioIds, Person = person, - PersonIds = RequestHelpers.GetGuids(personIds), - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(), + PersonIds = personIds, + PersonTypes = personTypes, + Years = years, ImageTypes = imageTypes, - VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(), + VideoTypes = videoTypes, AdjacentTo = adjacentTo, - ItemIds = RequestHelpers.GetGuids(ids), + ItemIds = ids, MinCommunityRating = minCommunityRating, MinCriticRating = minCriticRating, - ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId), + ParentId = parentId ?? Guid.Empty, ParentIndexNumber = parentIndexNumber, EnableTotalRecordCount = enableTotalRecordCount, - ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds), + ExcludeItemIds = excludeItemIds, DtoOptions = dtoOptions, SearchTerm = searchTerm, MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), @@ -360,7 +349,7 @@ namespace Jellyfin.Api.Controllers MaxPremiereDate = maxPremiereDate?.ToUniversalTime(), }; - if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm)) + if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm)) { query.CollapseBoxSetItems = false; } @@ -400,9 +389,9 @@ namespace Jellyfin.Api.Controllers } // Filter by Series Status - if (!string.IsNullOrEmpty(seriesStatus)) + if (seriesStatus.Length != 0) { - query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray(); + query.SeriesStatuses = seriesStatus; } // ExcludeLocationTypes @@ -411,13 +400,9 @@ namespace Jellyfin.Api.Controllers query.IsVirtualItem = false; } - if (!string.IsNullOrEmpty(locationTypes)) + if (locationTypes.Length > 0 && locationTypes.Length < 4) { - var requestedLocationTypes = locationTypes.Split(','); - if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4) - { - query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString()); - } + query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual); } // Min official rating @@ -433,9 +418,9 @@ namespace Jellyfin.Api.Controllers } // Artists - if (!string.IsNullOrEmpty(artists)) + if (artists.Length != 0) { - query.ArtistIds = artists.Split('|').Select(i => + query.ArtistIds = artists.Select(i => { try { @@ -449,29 +434,29 @@ namespace Jellyfin.Api.Controllers } // ExcludeArtistIds - if (!string.IsNullOrWhiteSpace(excludeArtistIds)) + if (excludeArtistIds.Length != 0) { - query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); + query.ExcludeArtistIds = excludeArtistIds; } - if (!string.IsNullOrWhiteSpace(albumIds)) + if (albumIds.Length != 0) { - query.AlbumIds = RequestHelpers.GetGuids(albumIds); + query.AlbumIds = albumIds; } // Albums - if (!string.IsNullOrEmpty(albums)) + if (albums.Length != 0) { - query.AlbumIds = albums.Split('|').SelectMany(i => + query.AlbumIds = albums.SelectMany(i => { return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 }); }).ToArray(); } // Studios - if (!string.IsNullOrEmpty(studios)) + if (studios.Length != 0) { - query.StudioIds = studios.Split('|').Select(i => + query.StudioIds = studios.Select(i => { try { @@ -508,18 +493,269 @@ namespace Jellyfin.Api.Controllers /// <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 delimeted.</param> + /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</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="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</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 delimeted. 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 delimeted.</param> + /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param> + /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. 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 delimeted. 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 delimeted.</param> + /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param> + /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param> + /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</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 delimeted.</param> + /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param> + /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</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 delimeted.</param> + /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</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 delimeted.</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 delimeted.</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 delimeted.</param> + /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</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] string? 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, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] bool? recursive, + [FromQuery] string? searchTerm, + [FromQuery] string? sortOrder, + [FromQuery] Guid? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, + [FromQuery] bool? isFavorite, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, + [FromQuery] 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, + 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); + } + + /// <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 delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</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 delimeted.</param> - /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</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> /// <response code="200">Items returned.</response> @@ -531,19 +767,19 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { var user = _userManager.GetUserById(userId); - var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); + var parentIdGuid = parentId ?? Guid.Empty; var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -569,13 +805,13 @@ namespace Jellyfin.Api.Controllers ParentId = parentIdGuid, Recursive = true, DtoOptions = dtoOptions, - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + MediaTypes = mediaTypes, IsVirtualItem = false, CollapseBoxSetItems = false, EnableTotalRecordCount = enableTotalRecordCount, AncestorIds = ancestorIds, - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, SearchTerm = searchTerm }); diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 1b115d800b..184843b397 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -362,15 +362,14 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public ActionResult DeleteItems([FromQuery] string? ids) + public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - if (string.IsNullOrEmpty(ids)) + if (ids.Length == 0) { return NoContent(); } - var itemIds = RequestHelpers.Split(ids, ',', true); - foreach (var i in itemIds) + foreach (var i in ids) { var item = _libraryManager.GetItemById(i); var auth = _authContext.GetAuthorizationInfo(Request); @@ -691,7 +690,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems( [FromRoute, Required] Guid itemId, - [FromQuery] string? excludeArtistIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) @@ -753,9 +752,9 @@ namespace Jellyfin.Api.Controllers }; // ExcludeArtistIds - if (!string.IsNullOrEmpty(excludeArtistIds)) + if (excludeArtistIds.Length != 0) { - query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); + query.ExcludeArtistIds = excludeArtistIds; } List<BaseItem> itemsResult = _libraryManager.GetItemList(query); diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 29c0f1df4d..56d4b3933e 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -17,7 +17,6 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LiveTvDtos; using Jellyfin.Data.Enums; -using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Dto; @@ -150,7 +149,7 @@ namespace Jellyfin.Api.Controllers [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, - [FromQuery] string? sortBy, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy, [FromQuery] SortOrder? sortOrder, [FromQuery] bool enableFavoriteSorting = false, [FromQuery] bool addCurrentProgram = true) @@ -175,7 +174,7 @@ namespace Jellyfin.Api.Controllers IsNews = isNews, IsKids = isKids, IsSports = isSports, - SortBy = RequestHelpers.Split(sortBy, ',', true), + SortBy = sortBy, SortOrder = sortOrder ?? SortOrder.Ascending, AddCurrentProgram = addCurrentProgram }, @@ -539,7 +538,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [Authorize(Policy = Policies.DefaultAuthorization)] public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms( - [FromQuery] string? channelIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds, [FromQuery] Guid? userId, [FromQuery] DateTime? minStartDate, [FromQuery] bool? hasAired, @@ -556,8 +555,8 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] string? sortBy, [FromQuery] string? sortOrder, - [FromQuery] string? genres, - [FromQuery] string? genreIds, + [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, @@ -573,8 +572,7 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ChannelIds = RequestHelpers.Split(channelIds, ',', true) - .Select(i => new Guid(i)).ToArray(), + ChannelIds = channelIds, HasAired = hasAired, IsAiring = isAiring, EnableTotalRecordCount = enableTotalRecordCount, @@ -591,8 +589,8 @@ namespace Jellyfin.Api.Controllers IsKids = isKids, IsSports = isSports, SeriesTimerId = seriesTimerId, - Genres = RequestHelpers.Split(genres, '|', true), - GenreIds = RequestHelpers.GetGuids(genreIds) + Genres = genres, + GenreIds = genreIds }; if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty)) @@ -628,8 +626,7 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true) - .Select(i => new Guid(i)).ToArray(), + ChannelIds = body.ChannelIds, HasAired = body.HasAired, IsAiring = body.IsAiring, EnableTotalRecordCount = body.EnableTotalRecordCount, @@ -646,8 +643,8 @@ namespace Jellyfin.Api.Controllers IsKids = body.IsKids, IsSports = body.IsSports, SeriesTimerId = body.SeriesTimerId, - Genres = RequestHelpers.Split(body.Genres, '|', true), - GenreIds = RequestHelpers.GetGuids(body.GenreIds) + Genres = body.Genres, + GenreIds = body.GenreIds }; if (!body.LibrarySeriesId.Equals(Guid.Empty)) @@ -703,7 +700,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? enableUserData, [FromQuery] bool enableTotalRecordCount = true) @@ -723,7 +720,7 @@ namespace Jellyfin.Api.Controllers IsNews = isNews, IsSports = isSports, EnableTotalRecordCount = enableTotalRecordCount, - GenreIds = RequestHelpers.GetGuids(genreIds) + GenreIds = genreIds }; var dtoOptions = new DtoOptions { Fields = fields } @@ -1017,7 +1014,9 @@ namespace Jellyfin.Api.Controllers if (!string.IsNullOrEmpty(pw)) { using var sha = SHA1.Create(); - listingsProviderInfo.Password = Hex.Encode(sha.ComputeHash(Encoding.UTF8.GetBytes(pw))); + // TODO: remove ToLower when Convert.ToHexString supports lowercase + // Schedules Direct requires the hex to be lowercase + listingsProviderInfo.Password = Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); } return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); @@ -1155,7 +1154,8 @@ namespace Jellyfin.Api.Controllers /// <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")] + [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) diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs index ef2e7e8b15..3d8b9e0cac 100644 --- a/Jellyfin.Api/Controllers/LocalizationController.cs +++ b/Jellyfin.Api/Controllers/LocalizationController.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Jellyfin.Api.Constants; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 186024585b..a76dc057a0 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Buffers; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -8,7 +8,6 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.MediaInfoDtos; -using Jellyfin.Api.Models.VideoDtos; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; @@ -81,6 +80,9 @@ namespace Jellyfin.Api.Controllers /// <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. + /// </remarks> /// <param name="itemId">The item id.</param> /// <param name="userId">The user id.</param> /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param> @@ -90,13 +92,13 @@ namespace Jellyfin.Api.Controllers /// <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="deviceProfile">The device profile.</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")] @@ -111,18 +113,17 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? maxAudioChannels, [FromQuery] string? mediaSourceId, [FromQuery] string? liveStreamId, - [FromBody] DeviceProfileDto? deviceProfile, - [FromQuery] bool autoOpenLiveStream = false, - [FromQuery] bool enableDirectPlay = true, - [FromQuery] bool enableDirectStream = true, - [FromQuery] bool enableTranscoding = true, - [FromQuery] bool allowVideoStreamCopy = true, - [FromQuery] bool allowAudioStreamCopy = true) + [FromQuery] bool? autoOpenLiveStream, + [FromQuery] bool? enableDirectPlay, + [FromQuery] bool? enableDirectStream, + [FromQuery] bool? enableTranscoding, + [FromQuery] bool? allowVideoStreamCopy, + [FromQuery] bool? allowAudioStreamCopy, + [FromBody] PlaybackInfoDto? playbackInfoDto) { var authInfo = _authContext.GetAuthorizationInfo(Request); - var profile = deviceProfile?.DeviceProfile; - + var profile = playbackInfoDto?.DeviceProfile; _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile); if (profile == null) @@ -134,6 +135,23 @@ namespace Jellyfin.Api.Controllers } } + // 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, @@ -161,18 +179,18 @@ namespace Jellyfin.Api.Controllers maxAudioChannels, info!.PlaySessionId!, userId ?? Guid.Empty, - enableDirectPlay, - enableDirectStream, - enableTranscoding, - allowVideoStreamCopy, - allowAudioStreamCopy, + enableDirectPlay.Value, + enableDirectStream.Value, + enableTranscoding.Value, + allowVideoStreamCopy.Value, + allowAudioStreamCopy.Value, Request.HttpContext.GetNormalizedRemoteIp()); } _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); } - if (autoOpenLiveStream) + if (autoOpenLiveStream.Value) { var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal)); @@ -183,9 +201,9 @@ namespace Jellyfin.Api.Controllers new LiveStreamRequest { AudioStreamIndex = audioStreamIndex, - DeviceProfile = deviceProfile?.DeviceProfile, - EnableDirectPlay = enableDirectPlay, - EnableDirectStream = enableDirectStream, + DeviceProfile = playbackInfoDto?.DeviceProfile, + EnableDirectPlay = enableDirectPlay.Value, + EnableDirectStream = enableDirectStream.Value, ItemId = itemId, MaxAudioChannels = maxAudioChannels, MaxStreamingBitrate = maxStreamingBitrate, diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index ebc148fe56..4d788ad7d3 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -65,7 +65,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("Recommendations")] public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations( [FromQuery] Guid? userId, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] int categoryLimit = 5, [FromQuery] int itemLimit = 8) @@ -78,7 +78,7 @@ namespace Jellyfin.Api.Controllers var categories = new List<RecommendationDto>(); - var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); + var parentIdGuid = parentId ?? Guid.Empty; var query = new InternalItemsQuery(user) { diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs index 229d9ff022..2608a9cd09 100644 --- a/Jellyfin.Api/Controllers/MusicGenresController.cs +++ b/Jellyfin.Api/Controllers/MusicGenresController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Linq; using Jellyfin.Api.Constants; @@ -72,10 +72,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool? isFavorite, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, @@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers var query = new InternalItemsQuery(user) { - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, @@ -109,21 +109,21 @@ namespace Jellyfin.Api.Controllers EnableTotalRecordCount = enableTotalRecordCount }; - if (!string.IsNullOrWhiteSpace(parentId)) + if (parentId.HasValue) { if (parentItem is Folder) { - query.AncestorIds = new[] { new Guid(parentId) }; + query.AncestorIds = new[] { parentId.Value }; } else { - query.ItemIds = new[] { new Guid(parentId) }; + query.ItemIds = new[] { parentId.Value }; } } var result = _libraryManager.GetMusicGenres(query); - var shouldIncludeItemTypes = !string.IsNullOrWhiteSpace(includeItemTypes); + var shouldIncludeItemTypes = includeItemTypes.Length != 0; return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index 1f797d6bc3..6295dfc05d 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -45,13 +45,13 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<PackageInfo>> GetPackageInfo( [FromRoute, Required] string name, - [FromQuery] string? assemblyGuid) + [FromQuery] Guid? assemblyGuid) { var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); var result = _installationManager.FilterPackages( packages, name, - string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid)) + assemblyGuid ?? default) .FirstOrDefault(); if (result == null) @@ -92,21 +92,21 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] public async Task<ActionResult> InstallPackage( [FromRoute, Required] string name, - [FromQuery] string? assemblyGuid, + [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.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)) + packages = packages.Where(p => p.versions.Where(q => q.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)).Any()) .ToList(); } var package = _installationManager.GetCompatibleVersions( packages, name, - string.IsNullOrEmpty(assemblyGuid) ? Guid.Empty : Guid.Parse(assemblyGuid), + assemblyGuid ?? Guid.Empty, specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version)) .FirstOrDefault(); diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs index 6ac3e6417a..17e631197c 100644 --- a/Jellyfin.Api/Controllers/PersonsController.cs +++ b/Jellyfin.Api/Controllers/PersonsController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Linq; using Jellyfin.Api.Constants; @@ -77,9 +77,9 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, - [FromQuery] string? excludePersonTypes, - [FromQuery] string? personTypes, - [FromQuery] string? appearsInItemId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes, + [FromQuery] Guid? appearsInItemId, [FromQuery] Guid? userId, [FromQuery] bool? enableImages = true) { @@ -97,12 +97,12 @@ namespace Jellyfin.Api.Controllers var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite); var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery { - PersonTypes = RequestHelpers.Split(personTypes, ',', true), - ExcludePersonTypes = RequestHelpers.Split(excludePersonTypes, ',', true), + PersonTypes = personTypes, + ExcludePersonTypes = excludePersonTypes, NameContains = searchTerm, User = user, IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite, - AppearsInItemId = string.IsNullOrEmpty(appearsInItemId) ? Guid.Empty : Guid.Parse(appearsInItemId), + AppearsInItemId = appearsInItemId ?? Guid.Empty, Limit = limit ?? 0 }); diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 4b3d8d3d39..3e55434c06 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; @@ -63,11 +63,10 @@ namespace Jellyfin.Api.Controllers public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist( [FromBody, Required] CreatePlaylistDto createPlaylistRequest) { - Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids); var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest { Name = createPlaylistRequest.Name, - ItemIdList = idGuidArray, + ItemIdList = createPlaylistRequest.Ids, UserId = createPlaylistRequest.UserId, MediaType = createPlaylistRequest.MediaType }).ConfigureAwait(false); @@ -87,10 +86,10 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> AddToPlaylist( [FromRoute, Required] Guid playlistId, - [FromQuery] string? ids, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, [FromQuery] Guid? userId) { - await _playlistManager.AddToPlaylistAsync(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty).ConfigureAwait(false); + await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false); return NoContent(); } @@ -122,9 +121,11 @@ namespace Jellyfin.Api.Controllers /// <returns>An <see cref="NoContentResult"/> on success.</returns> [HttpDelete("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveFromPlaylist([FromRoute, Required] string playlistId, [FromQuery] string? entryIds) + public async Task<ActionResult> RemoveFromPlaylist( + [FromRoute, Required] string playlistId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) { - await _playlistManager.RemoveFromPlaylistAsync(playlistId, RequestHelpers.Split(entryIds, ',', true)).ConfigureAwait(false); + await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 5c15e9a0d7..ec7b84ff60 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; @@ -74,7 +75,7 @@ namespace Jellyfin.Api.Controllers public ActionResult<UserItemDataDto> MarkPlayedItem( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, - [FromQuery] DateTime? datePlayed) + [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) { var user = _userManager.GetUserById(userId); var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request); diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 0f8ceba291..98f1bc2d23 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index e75f0d06bb..08255ff8f6 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -82,10 +83,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery] Guid? userId, [FromQuery, Required] string searchTerm, - [FromQuery] string? includeItemTypes, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? mediaTypes, - [FromQuery] string? parentId, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, + [FromQuery] Guid? parentId, [FromQuery] bool? isMovie, [FromQuery] bool? isSeries, [FromQuery] bool? isNews, @@ -108,9 +109,9 @@ namespace Jellyfin.Api.Controllers IncludeStudios = includeStudios, StartIndex = startIndex, UserId = userId ?? Guid.Empty, - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), - ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), - MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + IncludeItemTypes = includeItemTypes, + ExcludeItemTypes = excludeItemTypes, + MediaTypes = mediaTypes, ParentId = parentId, IsKids = isKids, diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index e506ac7bf1..e2269a2ce2 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -6,6 +6,7 @@ using System.Threading; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; +using Jellyfin.Api.Models.SessionDtos; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Devices; using MediaBrowser.Controller.Library; @@ -160,12 +161,12 @@ namespace Jellyfin.Api.Controllers public ActionResult Play( [FromRoute, Required] string sessionId, [FromQuery, Required] PlayCommand playCommand, - [FromQuery, Required] string itemIds, + [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds, [FromQuery] long? startPositionTicks) { var playRequest = new PlayRequest { - ItemIds = RequestHelpers.GetGuids(itemIds), + ItemIds = itemIds, StartPositionTicks = startPositionTicks, PlayCommand = playCommand }; @@ -378,7 +379,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult PostCapabilities( [FromQuery] string? id, - [FromQuery] string? playableMediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands, [FromQuery] bool supportsMediaControl = false, [FromQuery] bool supportsSync = false, @@ -391,7 +392,7 @@ namespace Jellyfin.Api.Controllers _sessionManager.ReportCapabilities(id, new ClientCapabilities { - PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true), + PlayableMediaTypes = playableMediaTypes, SupportedCommands = supportedCommands, SupportsMediaControl = supportsMediaControl, SupportsSync = supportsSync, @@ -412,14 +413,14 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult PostFullCapabilities( [FromQuery] string? id, - [FromBody, Required] ClientCapabilities capabilities) + [FromBody, Required] ClientCapabilitiesDto capabilities) { if (string.IsNullOrWhiteSpace(id)) { id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id; } - _sessionManager.ReportCapabilities(id, capabilities); + _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities()); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index 9c259cc198..d9cb34557b 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Models.StartupDtos; +using Jellyfin.Networking.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; @@ -72,9 +73,9 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration) { - _config.Configuration.UICulture = startupConfiguration.UICulture; - _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode; - _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage; + _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(); } @@ -89,9 +90,10 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto) { - _config.Configuration.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess; - _config.Configuration.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping; - _config.SaveConfiguration(); + NetworkConfiguration settings = _config.GetNetworkConfiguration(); + settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess; + settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping; + _config.SaveConfiguration("network", settings); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs index 27dcd51bc2..bb54c59f69 100644 --- a/Jellyfin.Api/Controllers/StudiosController.cs +++ b/Jellyfin.Api/Controllers/StudiosController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; @@ -71,10 +71,10 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? searchTerm, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool? isFavorite, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, @@ -94,13 +94,10 @@ namespace Jellyfin.Api.Controllers var parentItem = _libraryManager.GetParentItem(parentId, userId); - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, StartIndex = startIndex, Limit = limit, IsFavorite = isFavorite, @@ -112,20 +109,20 @@ namespace Jellyfin.Api.Controllers EnableTotalRecordCount = enableTotalRecordCount }; - if (!string.IsNullOrWhiteSpace(parentId)) + if (parentId.HasValue) { if (parentItem is Folder) { - query.AncestorIds = new[] { new Guid(parentId) }; + query.AncestorIds = new[] { parentId.Value }; } else { - query.ItemIds = new[] { new Guid(parentId) }; + query.ItemIds = new[] { parentId.Value }; } } var result = _libraryManager.GetStudios(query); - var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes); + var shouldIncludeItemTypes = includeItemTypes.Length != 0; return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user); } diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index a01ae31a0e..dcb8e803b8 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -193,7 +193,6 @@ namespace Jellyfin.Api.Controllers /// <response code="200">File returned.</response> /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")] - [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesFile("text/*")] public async Task<ActionResult> GetSubtitle( @@ -204,7 +203,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] long? endPositionTicks, [FromQuery] bool copyTimestamps = false, [FromQuery] bool addVttTimeMap = false, - [FromRoute] long startPositionTicks = 0) + [FromQuery] long startPositionTicks = 0) { if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase)) { @@ -250,6 +249,43 @@ namespace Jellyfin.Api.Controllers } /// <summary> + /// Gets subtitles in a specified format. + /// </summary> + /// <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">Optional. 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/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("text/*")] + public Task<ActionResult> GetSubtitleWithTicks( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string mediaSourceId, + [FromRoute, Required] int index, + [FromRoute, Required] long startPositionTicks, + [FromRoute, Required] string format, + [FromQuery] long? endPositionTicks, + [FromQuery] bool copyTimestamps = false, + [FromQuery] bool addVttTimeMap = false) + { + return GetSubtitle( + itemId, + mediaSourceId, + index, + format, + endPositionTicks, + copyTimestamps, + addVttTimeMap, + startPositionTicks); + } + + /// <summary> /// Gets an HLS subtitle playlist. /// </summary> /// <param name="itemId">The item id.</param> @@ -335,6 +371,7 @@ namespace Jellyfin.Api.Controllers /// <response code="204">Subtitle uploaded.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Videos/{itemId}/Subtitles")] + [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> UploadSubtitle( [FromRoute, Required] Guid itemId, [FromBody, Required] UploadSubtitleDto body) @@ -446,6 +483,7 @@ namespace Jellyfin.Api.Controllers [HttpGet("FallbackFont/Fonts/{name}")] [Authorize(Policy = Policies.DefaultAuthorization)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesFile("font/*")] public ActionResult GetFallbackFont([FromRoute, Required] string name) { var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs index ad64adfbad..9f1dec712a 100644 --- a/Jellyfin.Api/Controllers/SuggestionsController.cs +++ b/Jellyfin.Api/Controllers/SuggestionsController.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using System.Linq; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -58,8 +59,8 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetSuggestions( [FromRoute, Required] Guid userId, - [FromQuery] string? mediaType, - [FromQuery] string? type, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] type, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool enableTotalRecordCount = false) @@ -70,8 +71,8 @@ namespace Jellyfin.Api.Controllers var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) { OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(), - MediaTypes = RequestHelpers.Split(mediaType!, ',', true), - IncludeItemTypes = RequestHelpers.Split(type!, ',', true), + MediaTypes = mediaType, + IncludeItemTypes = type, IsVirtualItem = false, StartIndex = startIndex, Limit = limit, diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index e16a10ba4d..471c9180da 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -1,12 +1,15 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Threading; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.SyncPlayDtos; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -17,7 +20,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// The sync play controller. /// </summary> - [Authorize(Policy = Policies.DefaultAuthorization)] + [Authorize(Policy = Policies.SyncPlayAccess)] public class SyncPlayController : BaseJellyfinApiController { private readonly ISessionManager _sessionManager; @@ -43,35 +46,36 @@ namespace Jellyfin.Api.Controllers /// <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)] - public ActionResult SyncPlayCreateGroup() + [Authorize(Policy = Policies.SyncPlayCreateGroupAccess)] + public ActionResult SyncPlayCreateGroup( + [FromBody, Required] NewGroupRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - _syncPlayManager.NewGroup(currentSession, CancellationToken.None); + var syncPlayRequest = new NewGroupRequest(requestData.GroupName); + _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } /// <summary> /// Join an existing SyncPlay group. /// </summary> - /// <param name="groupId">The sync play group id.</param> + /// <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)] - public ActionResult SyncPlayJoinGroup([FromQuery, Required] Guid groupId) + [Authorize(Policy = Policies.SyncPlayAccess)] + public ActionResult SyncPlayJoinGroup( + [FromBody, Required] JoinGroupRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - - var joinRequest = new JoinGroupRequest() - { - GroupId = groupId - }; - - _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None); + var syncPlayRequest = new JoinGroupRequest(requestData.GroupId); + _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -85,38 +89,125 @@ namespace Jellyfin.Api.Controllers public ActionResult SyncPlayLeaveGroup() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None); + var syncPlayRequest = new LeaveGroupRequest(); + _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } /// <summary> /// Gets all SyncPlay groups. /// </summary> - /// <param name="filterItemId">Optional. Filter by item id.</param> /// <response code="200">Groups returned.</response> /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns> [HttpGet("List")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<GroupInfoView>> SyncPlayGetGroups([FromQuery] Guid? filterItemId) + [Authorize(Policy = Policies.SyncPlayAccess)] + public ActionResult<IEnumerable<GroupInfoDto>> SyncPlayGetGroups() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new ListGroupsRequest(); + return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest)); + } + + /// <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)] + public ActionResult SyncPlaySetNewQueue( + [FromBody, Required] PlayRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty)); + var syncPlayRequest = new PlayGroupRequest( + requestData.PlayingQueue, + requestData.PlayingItemPosition, + requestData.StartPositionTicks); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); } /// <summary> - /// Request play in SyncPlay group. + /// Request to change playlist item in SyncPlay group. /// </summary> - /// <response code="204">Play request sent to all group members.</response> + /// <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("Play")] + [HttpPost("SetPlaylistItem")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayPlay() + public ActionResult SyncPlaySetPlaylistItem( + [FromBody, Required] SetPlaylistItemRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Play - }; + 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)] + public ActionResult SyncPlayRemoveFromPlaylist( + [FromBody, Required] RemoveFromPlaylistRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds); + _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)] + public ActionResult SyncPlayMovePlaylistItem( + [FromBody, Required] MovePlaylistItemRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + 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)] + public ActionResult SyncPlayQueue( + [FromBody, Required] QueueRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + 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)] + public ActionResult SyncPlayUnpause() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new UnpauseGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -124,17 +215,29 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Request pause in SyncPlay group. /// </summary> - /// <response code="204">Pause request sent to all group members.</response> + /// <response code="204">Pause update sent to all group members.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Pause")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SyncPlayPause() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Pause - }; + 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)] + public ActionResult SyncPlayStop() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new StopGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -142,42 +245,143 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Request seek in SyncPlay group. /// </summary> - /// <param name="positionTicks">The playback position in ticks.</param> - /// <response code="204">Seek request sent to all group members.</response> + /// <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)] - public ActionResult SyncPlaySeek([FromQuery] long positionTicks) + public ActionResult SyncPlaySeek( + [FromBody, Required] SeekRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Seek, - PositionTicks = positionTicks - }; + var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } /// <summary> - /// Request group wait in SyncPlay group while buffering. + /// Notify SyncPlay group that member is buffering. /// </summary> - /// <param name="when">When the request has been made by the client.</param> - /// <param name="positionTicks">The playback position in ticks.</param> - /// <param name="bufferingDone">Whether the buffering is done.</param> - /// <response code="204">Buffering request sent to all group members.</response> + /// <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)] - public ActionResult SyncPlayBuffering([FromQuery] DateTime when, [FromQuery] long positionTicks, [FromQuery] bool bufferingDone) + public ActionResult SyncPlayBuffering( + [FromBody, Required] BufferRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + 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)] + public ActionResult SyncPlayReady( + [FromBody, Required] ReadyRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + 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)] + public ActionResult SyncPlaySetIgnoreWait( + [FromBody, Required] IgnoreWaitRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + 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)] + public ActionResult SyncPlayNextItem( + [FromBody, Required] NextItemRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + 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)] + public ActionResult SyncPlayPreviousItem( + [FromBody, Required] PreviousItemRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + 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)] + public ActionResult SyncPlaySetRepeatMode( + [FromBody, Required] SetRepeatModeRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + 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)] + public ActionResult SyncPlaySetShuffleMode( + [FromBody, Required] SetShuffleModeRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = bufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer, - When = when, - PositionTicks = positionTicks - }; + var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -185,19 +389,16 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Update session ping. /// </summary> - /// <param name="ping">The ping.</param> + /// <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 ActionResult SyncPlayPing([FromQuery] double ping) + public ActionResult SyncPlayPing( + [FromBody, Required] PingRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Ping, - Ping = Convert.ToInt64(ping) - }; + 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 4cb1984a2f..7784e8a119 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.IO; @@ -64,9 +64,9 @@ namespace Jellyfin.Api.Controllers [HttpGet("Info")] [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<SystemInfo>> GetSystemInfo() + public ActionResult<SystemInfo> GetSystemInfo() { - return await _appHost.GetSystemInfo(CancellationToken.None).ConfigureAwait(false); + return _appHost.GetSystemInfo(Request.HttpContext.Connection.RemoteIpAddress); } /// <summary> @@ -76,9 +76,9 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns> [HttpGet("Info/Public")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PublicSystemInfo>> GetPublicSystemInfo() + public ActionResult<PublicSystemInfo> GetPublicSystemInfo() { - return await _appHost.GetPublicSystemInfo(CancellationToken.None).ConfigureAwait(false); + return _appHost.GetPublicSystemInfo(Request.HttpContext.Connection.RemoteIpAddress); } /// <summary> diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index 2dc744e7ca..c730ac12b3 100644 --- a/Jellyfin.Api/Controllers/TimeSyncController.cs +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using MediaBrowser.Model.SyncPlay; using Microsoft.AspNetCore.Http; @@ -13,7 +13,7 @@ namespace Jellyfin.Api.Controllers public class TimeSyncController : BaseJellyfinApiController { /// <summary> - /// Gets the current utc time. + /// 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> @@ -22,18 +22,14 @@ namespace Jellyfin.Api.Controllers public ActionResult<UtcTimeResponse> GetUtcTime() { // Important to keep the following line at the beginning - var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo); + var requestReceptionTime = DateTime.UtcNow.ToUniversalTime(); - var response = new UtcTimeResponse(); - response.RequestReceptionTime = requestReceptionTime; - - // Important to keep the following two lines at the end - var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo); - response.ResponseTransmissionTime = responseTransmissionTime; + // Important to keep the following line at the end + var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime(); // Implementing NTP on such a high level results in this useless // information being sent. On the other hand it enables future additions. - return response; + return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime); } } } diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index d78adcbcdc..8e9ece14f8 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -1,4 +1,4 @@ -using System; +using System; using Jellyfin.Api.Constants; using Jellyfin.Api.ModelBinders; using MediaBrowser.Model.Dto; @@ -42,8 +42,8 @@ namespace Jellyfin.Api.Controllers /// <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 delimeted.</param> - /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</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> @@ -56,41 +56,41 @@ namespace Jellyfin.Api.Controllers /// <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="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</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 delimeted. 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 delimeted.</param> - /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</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 delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</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 delimeted.</param> - /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param> - /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param> - /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</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 delimeted.</param> - /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param> - /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</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 delimeted.</param> - /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</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 delimeted.</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> @@ -101,12 +101,12 @@ namespace Jellyfin.Api.Controllers /// <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 delimeted.</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 delimeted.</param> - /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</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> @@ -125,7 +125,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, [FromQuery] bool? is4K, - [FromQuery] string? locationTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes, [FromQuery] bool? isMissing, [FromQuery] bool? isUnaired, @@ -139,41 +139,41 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? hasImdbId, [FromQuery] bool? hasTmdbId, [FromQuery] bool? hasTvdbId, - [FromQuery] string? excludeItemIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? recursive, [FromQuery] string? searchTerm, [FromQuery] string? sortOrder, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters, [FromQuery] bool? isFavorite, - [FromQuery] string? mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes, [FromQuery] string? sortBy, [FromQuery] bool? isPlayed, - [FromQuery] string? genres, - [FromQuery] string? officialRatings, - [FromQuery] string? tags, - [FromQuery] string? years, + [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] string? personIds, - [FromQuery] string? personTypes, - [FromQuery] string? studios, - [FromQuery] string? artists, - [FromQuery] string? excludeArtistIds, - [FromQuery] string? artistIds, - [FromQuery] string? albumArtistIds, - [FromQuery] string? contributingArtistIds, - [FromQuery] string? albums, - [FromQuery] string? albumIds, - [FromQuery] string? ids, - [FromQuery] string? videoTypes, + [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, @@ -184,21 +184,20 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, [FromQuery] bool? is3D, - [FromQuery] string? seriesStatus, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus, [FromQuery] string? nameStartsWithOrGreater, [FromQuery] string? nameStartsWith, [FromQuery] string? nameLessThan, - [FromQuery] string? studioIds, - [FromQuery] string? genreIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool? enableImages = true) { - var includeItemTypes = "Trailer"; + var includeItemTypes = new[] { "Trailer" }; return _itemsController .GetItems( userId, - userId, maxOfficialRating, hasThemeSong, hasThemeVideo, diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 6fd154836b..03fd1846d7 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -76,7 +76,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] string? seriesId, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery] bool? enableImges, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, @@ -132,7 +132,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery] bool? enableImges, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, @@ -144,7 +144,7 @@ namespace Jellyfin.Api.Controllers var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1); - var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId); + var parentIdGuid = parentId ?? Guid.Empty; var options = new DtoOptions { Fields = fields } .AddClientFields(Request) @@ -176,7 +176,7 @@ namespace Jellyfin.Api.Controllers /// </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 delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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> @@ -188,20 +188,20 @@ namespace Jellyfin.Api.Controllers /// <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 delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</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] string seriesId, + [FromRoute, Required] Guid seriesId, [FromQuery] Guid? userId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] int? season, - [FromQuery] string? seasonId, + [FromQuery] Guid? seasonId, [FromQuery] bool? isMissing, [FromQuery] string? adjacentTo, - [FromQuery] string? startItemId, + [FromQuery] Guid? startItemId, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] bool? enableImages, @@ -220,9 +220,9 @@ namespace Jellyfin.Api.Controllers .AddClientFields(Request) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!); - if (!string.IsNullOrWhiteSpace(seasonId)) // Season id was supplied. Get episodes by season id. + if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. { - var item = _libraryManager.GetItemById(new Guid(seasonId)); + var item = _libraryManager.GetItemById(seasonId.Value); if (!(item is Season seasonItem)) { return NotFound("No season exists with Id " + seasonId); @@ -264,10 +264,10 @@ namespace Jellyfin.Api.Controllers .ToList(); } - if (!string.IsNullOrWhiteSpace(startItemId)) + if (startItemId.HasValue) { episodes = episodes - .SkipWhile(i => !string.Equals(i.Id.ToString("N", CultureInfo.InvariantCulture), startItemId, StringComparison.OrdinalIgnoreCase)) + .SkipWhile(i => startItemId.Value.Equals(i.Id)) .ToList(); } @@ -303,7 +303,7 @@ namespace Jellyfin.Api.Controllers /// </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 delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</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> @@ -316,7 +316,7 @@ namespace Jellyfin.Api.Controllers [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetSeasons( - [FromRoute, Required] string seriesId, + [FromRoute, Required] Guid seriesId, [FromQuery] Guid? userId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery] bool? isSpecialSeason, diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index e10f1fe912..34c9f32fa4 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Devices; @@ -96,7 +97,7 @@ namespace Jellyfin.Api.Controllers [ProducesAudioFile] public async Task<ActionResult> GetUniversalAudioStream( [FromRoute, Required] Guid itemId, - [FromQuery] string? container, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container, [FromQuery] string? mediaSourceId, [FromQuery] string? deviceId, [FromQuery] Guid? userId, @@ -191,8 +192,11 @@ namespace Jellyfin.Api.Controllers 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[] { "mpegts", "fmp4" }; + var supportedHlsContainers = new[] { "ts", "mp4" }; var dynamicHlsRequestDto = new HlsAudioRequestDto { @@ -201,7 +205,7 @@ namespace Jellyfin.Api.Controllers 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 : "mpegts", + SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts", MediaSourceId = mediaSourceId, DeviceId = deviceId, AudioCodec = audioCodec, @@ -258,7 +262,7 @@ namespace Jellyfin.Api.Controllers } private DeviceProfile GetDeviceProfile( - string? container, + string[] containers, string? transcodingContainer, string? audioCodec, string? transcodingProtocol, @@ -270,7 +274,6 @@ namespace Jellyfin.Api.Controllers { var deviceProfile = new DeviceProfile(); - var containers = RequestHelpers.Split(container, ',', true); int len = containers.Length; var directPlayProfiles = new DirectPlayProfile[len]; for (int i = 0; i < len; i++) @@ -327,7 +330,7 @@ namespace Jellyfin.Api.Controllers if (conditions.Count > 0) { // codec profile - codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = container, Conditions = conditions.ToArray() }); + codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = string.Join(',', containers), Conditions = conditions.ToArray() }); } deviceProfile.CodecProfiles = codecProfiles.ToArray(); diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index ff047c68a0..df55b81859 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index cfd8511297..0e65591cce 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -253,7 +253,7 @@ namespace Jellyfin.Api.Controllers /// <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 delimeted.</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> @@ -269,7 +269,7 @@ namespace Jellyfin.Api.Controllers [FromRoute, Required] Guid userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, [FromQuery] bool? isPlayed, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, @@ -296,7 +296,7 @@ namespace Jellyfin.Api.Controllers new LatestItemsQuery { GroupItems = groupItems, - IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + IncludeItemTypes = includeItemTypes, IsPlayed = isPlayed, Limit = limit, ParentId = parentId ?? Guid.Empty, diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index d575bfc3b8..e1483ce9d5 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Linq; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.UserViewDtos; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -67,7 +68,7 @@ namespace Jellyfin.Api.Controllers public ActionResult<QueryResult<BaseItemDto>> GetUserViews( [FromRoute, Required] Guid userId, [FromQuery] bool? includeExternalContent, - [FromQuery] string? presetViews, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews, [FromQuery] bool includeHidden = false) { var query = new UserViewQuery @@ -81,9 +82,9 @@ namespace Jellyfin.Api.Controllers query.IncludeExternalContent = includeExternalContent.Value; } - if (!string.IsNullOrWhiteSpace(presetViews)) + if (presetViews.Length != 0) { - query.PresetViews = RequestHelpers.Split(presetViews, ',', true); + query.PresetViews = presetViews; } var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty; diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index 418c0c1230..c2bb0dfffe 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Attributes; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -43,7 +44,7 @@ namespace Jellyfin.Api.Controllers /// <response code="404">Video or attachment not found.</response> /// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns> [HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")] - [Produces(MediaTypeNames.Application.Octet)] + [ProducesFile(MediaTypeNames.Application.Octet)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> GetAttachment( diff --git a/Jellyfin.Api/Controllers/VideoHlsController.cs b/Jellyfin.Api/Controllers/VideoHlsController.cs index 389dc8a088..7e743ee0c7 100644 --- a/Jellyfin.Api/Controllers/VideoHlsController.cs +++ b/Jellyfin.Api/Controllers/VideoHlsController.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; @@ -145,14 +146,14 @@ namespace Jellyfin.Api.Controllers /// <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="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="transcodingReasons">Optional. The transcoding reason.</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> @@ -210,7 +211,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext context, @@ -265,7 +266,7 @@ namespace Jellyfin.Api.Controllers EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context, @@ -296,23 +297,23 @@ namespace Jellyfin.Api.Controllers .ConfigureAwait(false); TranscodingJobDto? job = null; - var playlist = state.OutputFilePath; + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); - if (!System.IO.File.Exists(playlist)) + if (!System.IO.File.Exists(playlistPath)) { - var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlist); + var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath); await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); try { - if (!System.IO.File.Exists(playlist)) + if (!System.IO.File.Exists(playlistPath)) { // If the playlist doesn't already exist, startup ffmpeg try { job = await _transcodingJobHelper.StartFfMpeg( state, - playlist, - GetCommandLineArguments(playlist, state), + playlistPath, + GetCommandLineArguments(playlistPath, state), Request, TranscodingJobType, cancellationTokenSource) @@ -328,7 +329,7 @@ namespace Jellyfin.Api.Controllers minSegments = state.MinSegments; if (minSegments > 0) { - await HlsHelpers.WaitForMinimumSegmentCount(playlist, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false); + await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false); } } } @@ -338,14 +339,14 @@ namespace Jellyfin.Api.Controllers } } - job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlist, TranscodingJobType); + job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); if (job != null) { _transcodingJobHelper.OnTranscodeEndRequest(job); } - var playlistText = HlsHelpers.GetLivePlaylistText(playlist, state.SegmentLength); + var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state); return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8")); } @@ -359,17 +360,46 @@ namespace Jellyfin.Api.Controllers private string GetCommandLineArguments(string outputPath, StreamState state) { var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); + var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); // GetNumberOfThreads is static. var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions); - var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts"; + var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty; + var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)); - var outputTsArg = Path.Combine(directory, Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format; + var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath); + var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension); + var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer); + var outputTsArg = outputPrefix + "%d" + outputExtension; - var segmentFormat = format.TrimStart('.'); + var segmentFormat = outputExtension.TrimStart('.'); if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) { segmentFormat = "mpegts"; } + else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)) + { + var outputFmp4HeaderArg = string.Empty; + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + if (isWindows) + { + // on Windows, the path of fmp4 header file needs to be configured + outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\""; + } + else + { + // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder + outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""; + } + + segmentFormat = "fmp4" + outputFmp4HeaderArg; + } + else + { + _logger.LogError("Invalid HLS segment container: {SegmentFormat}", segmentFormat); + } + + var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128 + ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture) + : "128"; var baseUrlParam = string.Format( CultureInfo.InvariantCulture, @@ -378,20 +408,19 @@ namespace Jellyfin.Api.Controllers return string.Format( CultureInfo.InvariantCulture, - "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"", + "{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 0 -hls_base_url {9} -hls_playlist_type event -hls_segment_filename \"{10}\" -y \"{11}\"", inputModifier, _encodingHelper.GetInputArgument(state, _encodingOptions), threads, - _encodingHelper.GetMapArgs(state), + mapArgs, GetVideoArguments(state), GetAudioArguments(state), + maxMuxingQueueSize, state.SegmentLength.ToString(CultureInfo.InvariantCulture), - string.Empty, segmentFormat, baseUrlParam, - outputPath, - outputTsArg) - .Trim(); + outputTsArg, + outputPath).Trim(); } /// <summary> @@ -401,14 +430,53 @@ namespace Jellyfin.Api.Controllers /// <returns>The command line arguments for audio transcoding.</returns> private string GetAudioArguments(StreamState state) { - var codec = _encodingHelper.GetAudioEncoder(state); + if (state.AudioStream == null) + { + return string.Empty; + } - if (EncodingHelper.IsCopyCodec(codec)) + var audioCodec = _encodingHelper.GetAudioEncoder(state); + + if (!state.IsOutputVideo) + { + 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; + + if (state.OutputAudioBitrate.HasValue) + { + audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture); + } + + if (state.OutputAudioChannels.HasValue) + { + audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture); + } + + if (state.OutputAudioSampleRate.HasValue) + { + audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); + } + + audioTranscodeParams += " -vn"; + return audioTranscodeParams; + } + + if (EncodingHelper.IsCopyCodec(audioCodec)) { - return "-codec:a:0 copy"; + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + + return "-acodec copy -strict -2" + bitStreamArgs; } - var args = "-codec:a:0 " + codec; + var args = "-codec:a:0 " + audioCodec; var channels = state.OutputAudioChannels; @@ -429,7 +497,7 @@ namespace Jellyfin.Api.Controllers args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - args += " " + _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); + args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true); return args; } @@ -441,6 +509,11 @@ namespace Jellyfin.Api.Controllers /// <returns>The command line arguments for video transcoding.</returns> private string GetVideoArguments(StreamState state) { + if (state.VideoStream == null) + { + return string.Empty; + } + if (!state.IsOutputVideo) { return string.Empty; @@ -450,47 +523,65 @@ namespace Jellyfin.Api.Controllers var args = "-codec:v:0 " + codec; + // Prefer hvc1 to hev1. + 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)) + { + args += " -tag:v:0 hvc1"; + } + // if (state.EnableMpegtsM2TsMode) // { // args += " -mpegts_m2ts_mode 1"; // } - // See if we can save come cpu cycles by avoiding encoding - if (codec.Equals("copy", 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 != null && - !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) + // If h264_mp4toannexb is ever added, do not use it for live tv. + if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) { - string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream); + string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream); if (!string.IsNullOrEmpty(bitStreamArgs)) { args += " " + bitStreamArgs; } } + + args += " -start_at_zero"; } else { - var keyFrameArg = string.Format( - CultureInfo.InvariantCulture, - " -force_key_frames \"expr:gte(t,n_forced*{0})\"", - state.SegmentLength.ToString(CultureInfo.InvariantCulture)); + args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset); - var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + // Set the key frame params for video encoding to match the hls segment time. + args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, true, null); - args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg; - - // Add resolution params, if specified - if (!hasGraphicalSubs) + // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now. + if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)) { - args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); + args += " -bf 0"; } - // This is for internal graphical subs + var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + if (hasGraphicalSubs) { + // Graphical subs overlay and resolution params. args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec); } + else + { + // Resolution params. + args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec); + } + + if (state.SubtitleStream == null || !state.SubtitleStream.IsExternal || state.SubtitleStream.IsTextSubtitleStream) + { + args += " -start_at_zero"; + } } args += " -flags -global_header"; diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 4de7aac71d..44dc639523 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -10,6 +10,7 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; +using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.StreamingDtos; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; @@ -195,7 +196,7 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Merges videos into a single record. /// </summary> - /// <param name="itemIds">Item id list. This allows multiple, comma delimited.</param> + /// <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> @@ -203,9 +204,9 @@ namespace Jellyfin.Api.Controllers [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task<ActionResult> MergeVersions([FromQuery, Required] string itemIds) + public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { - var items = RequestHelpers.Split(itemIds, ',', true) + var items = ids .Select(i => _libraryManager.GetItemById(i)) .OfType<Video>() .OrderBy(i => i.Id) @@ -283,7 +284,7 @@ namespace Jellyfin.Api.Controllers /// <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 lenght.</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> @@ -312,29 +313,27 @@ namespace Jellyfin.Api.Controllers /// <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="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="transcodingReasons">Optional. The transcoding reason.</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=stream}.{container?}", Name = "GetVideoStreamWithExt")] [HttpGet("{itemId}/stream")] - [HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStreamWithExt")] [HttpHead("{itemId}/stream", Name = "HeadVideoStream")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesVideoFile] public async Task<ActionResult> GetVideoStream( [FromRoute, Required] Guid itemId, - [FromRoute] string? container, + [FromQuery] string? container, [FromQuery] bool? @static, [FromQuery] string? @params, [FromQuery] string? tag, @@ -377,7 +376,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] string? videoCodec, [FromQuery] string? subtitleCodec, - [FromQuery] string? transcodingReasons, + [FromQuery] string? transcodeReasons, [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext context, @@ -431,7 +430,7 @@ namespace Jellyfin.Api.Controllers EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true, VideoCodec = videoCodec, SubtitleCodec = subtitleCodec, - TranscodeReasons = transcodingReasons, + TranscodeReasons = transcodeReasons, AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context, @@ -529,5 +528,166 @@ namespace Jellyfin.Api.Controllers _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 lenght.</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, 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("{itemId}/{stream=stream}.{container}")] + [HttpHead("{itemId}/{stream=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? 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, + 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 1b38e399d6..48c639b08d 100644 --- a/Jellyfin.Api/Controllers/YearsController.cs +++ b/Jellyfin.Api/Controllers/YearsController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; @@ -71,11 +71,11 @@ namespace Jellyfin.Api.Controllers [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] string? sortOrder, - [FromQuery] string? parentId, + [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, - [FromQuery] string? excludeItemTypes, - [FromQuery] string? includeItemTypes, - [FromQuery] string? mediaTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes, [FromQuery] string? sortBy, [FromQuery] bool? enableUserData, [FromQuery] int? imageTypeLimit, @@ -89,33 +89,24 @@ namespace Jellyfin.Api.Controllers .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); User? user = null; - BaseItem parentItem; + BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId); if (userId.HasValue && !userId.Equals(Guid.Empty)) { user = _userManager.GetUserById(userId.Value); - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId); - } - else - { - parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId); } IList<BaseItem> items; - var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true); - var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true); - var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true); - var query = new InternalItemsQuery(user) { - ExcludeItemTypes = excludeItemTypesArr, - IncludeItemTypes = includeItemTypesArr, - MediaTypes = mediaTypesArr, + ExcludeItemTypes = excludeItemTypes, + IncludeItemTypes = includeItemTypes, + MediaTypes = mediaTypes, DtoOptions = dtoOptions }; - bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr); + bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes); if (parentItem.IsFolder) { diff --git a/Jellyfin.Api/Helpers/ClassMigrationHelper.cs b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs new file mode 100644 index 0000000000..a911a33241 --- /dev/null +++ b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs @@ -0,0 +1,71 @@ +using System; +using System.Reflection; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// A static class for copying matching properties from one object to another. + /// TODO: remove at the point when a fixed migration path has been decided upon. + /// </summary> + public static class ClassMigrationHelper + { + /// <summary> + /// Extension for 'Object' that copies the properties to a destination object. + /// </summary> + /// <param name="source">The source.</param> + /// <param name="destination">The destination.</param> + public static void CopyProperties(this object source, object destination) + { + // If any this null throw an exception. + if (source == null || destination == null) + { + throw new Exception("Source or/and Destination Objects are null"); + } + + // Getting the Types of the objects. + Type typeDest = destination.GetType(); + Type typeSrc = source.GetType(); + + // Iterate the Properties of the source instance and populate them from their destination counterparts. + PropertyInfo[] srcProps = typeSrc.GetProperties(); + foreach (PropertyInfo srcProp in srcProps) + { + if (!srcProp.CanRead) + { + continue; + } + + var targetProperty = typeDest.GetProperty(srcProp.Name); + if (targetProperty == null) + { + continue; + } + + if (!targetProperty.CanWrite) + { + continue; + } + + var obj = targetProperty.GetSetMethod(true); + if (obj != null && obj.IsPrivate) + { + continue; + } + + var target = targetProperty.GetSetMethod(); + if (target != null && (target.Attributes & MethodAttributes.Static) != 0) + { + continue; + } + + if (!targetProperty.PropertyType.IsAssignableFrom(srcProp.PropertyType)) + { + continue; + } + + // Passed all tests, lets set the value. + targetProperty.SetValue(destination, srcProp.GetValue(source, null), null); + } + } + } +} diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index e7fac50c64..a4da54cfdf 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -207,7 +207,61 @@ namespace Jellyfin.Api.Helpers AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); } - AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + + if (state.VideoStream != null && state.VideoRequest != null) + { + // Provide SDR HEVC entrance for backward compatibility. + if (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 != 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"; + + EncodingHelper encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration); + var sdrOutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec) ?? 0; + var sdrOutputAudioBitrate = encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0; + var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; + + AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); + + // 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 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); + } + } if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp())) { @@ -217,40 +271,77 @@ namespace Jellyfin.Api.Helpers var variation = GetBitrateVariation(totalBitrate); var newBitrate = totalBitrate - variation; - var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); variation *= 2; newBitrate = totalBitrate - variation; - variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); } return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8")); } - private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) + private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup) { - builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") + var playlistBuilder = new StringBuilder(); + playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=") .Append(bitrate.ToString(CultureInfo.InvariantCulture)) .Append(",AVERAGE-BANDWIDTH=") .Append(bitrate.ToString(CultureInfo.InvariantCulture)); - AppendPlaylistCodecsField(builder, state); + AppendPlaylistVideoRangeField(playlistBuilder, state); + + AppendPlaylistCodecsField(playlistBuilder, state); - AppendPlaylistResolutionField(builder, state); + AppendPlaylistResolutionField(playlistBuilder, state); - AppendPlaylistFramerateField(builder, state); + AppendPlaylistFramerateField(playlistBuilder, state); if (!string.IsNullOrWhiteSpace(subtitleGroup)) { - builder.Append(",SUBTITLES=\"") + playlistBuilder.Append(",SUBTITLES=\"") .Append(subtitleGroup) .Append('"'); } - builder.Append(Environment.NewLine); - builder.AppendLine(url); + 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 != null && !string.IsNullOrEmpty(state.VideoStream.VideoRange)) + { + 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 + { + // Currently we only encode to SDR. + builder.Append(",VIDEO-RANGE=SDR"); + } + } } /// <summary> @@ -419,15 +510,27 @@ namespace Jellyfin.Api.Helpers /// <returns>H.26X level of the output video stream.</returns> private int? GetOutputVideoCodecLevel(StreamState state) { - string? levelString; + string levelString = string.Empty; if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) + && state.VideoStream != null && state.VideoStream.Level.HasValue) { - levelString = state.VideoStream?.Level.ToString(); + levelString = state.VideoStream.Level.ToString() ?? string.Empty; } else { - levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec); + 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)) @@ -439,6 +542,38 @@ namespace Jellyfin.Api.Helpers } /// <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; + } + else if (!string.IsNullOrEmpty(codec)) + { + profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty; + if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + profileString = profileString ?? "high"; + } + + if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)) + { + profileString = profileString ?? "main"; + } + } + + return profileString; + } + + /// <summary> /// Gets a formatted string of the output audio codec, for use in the CODECS field. /// </summary> /// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/> @@ -468,6 +603,16 @@ namespace Jellyfin.Api.Helpers return HlsCodecStringHelpers.GetEAC3String(); } + if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetFLACString(); + } + + if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase)) + { + return HlsCodecStringHelpers.GetALACString(); + } + return string.Empty; } @@ -492,15 +637,14 @@ namespace Jellyfin.Api.Helpers if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) { - string? profile = state.GetRequestedProfiles("h264").FirstOrDefault(); + string profile = GetOutputVideoCodecProfile(state, "h264"); return HlsCodecStringHelpers.GetH264String(profile, level); } if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)) { - string? profile = state.GetRequestedProfiles("h265").FirstOrDefault(); - + string profile = GetOutputVideoCodecProfile(state, "hevc"); return HlsCodecStringHelpers.GetH265String(profile, level); } @@ -544,12 +688,30 @@ namespace Jellyfin.Api.Helpers return variation; } - private string ReplaceBitrate(string url, int oldValue, int newValue) + 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); + } + + private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) + { + var oldPlaylist = playlist.ToString(); + return oldPlaylist.Replace( + oldValue.ToString(), + newValue.ToString(), + StringComparison.OrdinalIgnoreCase); + } } } diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 20c94cddad..cfa2c1229a 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -24,12 +24,14 @@ namespace Jellyfin.Api.Helpers /// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</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, bool isHeadRequest, HttpClient httpClient, - HttpContext httpContext) + HttpContext httpContext, + CancellationToken cancellationToken = default) { if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent)) { @@ -47,7 +49,7 @@ namespace Jellyfin.Api.Helpers return new FileContentResult(Array.Empty<byte>(), contentType); } - return new FileStreamResult(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType); + return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType); } /// <summary> diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index 1bd3d67ff2..a5369c441c 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -10,12 +10,37 @@ namespace Jellyfin.Api.Helpers public static class HlsCodecStringHelpers { /// <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> /// Gets a MP3 codec string. /// </summary> /// <returns>MP3 codec string.</returns> public static string GetMP3String() { - return "mp4a.40.34"; + return MP3; } /// <summary> @@ -41,6 +66,42 @@ namespace Jellyfin.Api.Helpers } /// <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 a H.264 codec string. /// </summary> /// <param name="profile">H.264 profile.</param> @@ -85,41 +146,24 @@ namespace Jellyfin.Api.Helpers // 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("hev1", 16); + StringBuilder result = new StringBuilder("hvc1", 16); - if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase) + || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase)) { - result.Append(".2.6"); + result.Append(".2.4"); } else { // Default to main if profile is invalid - result.Append(".1.6"); + result.Append(".1.4"); } result.Append(".L") - .Append(level * 3) + .Append(level) .Append(".B0"); return result.ToString(); } - - /// <summary> - /// Gets an AC-3 codec string. - /// </summary> - /// <returns>AC-3 codec string.</returns> - public static string GetAC3String() - { - return "mp4a.a5"; - } - - /// <summary> - /// Gets an E-AC-3 codec string. - /// </summary> - /// <returns>E-AC-3 codec string.</returns> - public static string GetEAC3String() - { - return "mp4a.a6"; - } } } diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs index 45ce905668..18e23fb5c8 100644 --- a/Jellyfin.Api/Helpers/HlsHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsHelpers.cs @@ -1,8 +1,11 @@ using System; using System.Globalization; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Models.StreamingDtos; +using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; @@ -75,24 +78,64 @@ namespace Jellyfin.Api.Helpers } /// <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; + } + + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + if (isWindows) + { + // on Windows + // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4" + fmp4InitFileName = outputPrefix + "-1" + outputExtension; + } + + return fmp4InitFileName; + } + + /// <summary> /// Gets the hls playlist text. /// </summary> /// <param name="path">The path to the playlist file.</param> - /// <param name="segmentLength">The segment length.</param> + /// <param name="state">The <see cref="StreamState"/>.</param> /// <returns>The playlist text as a string.</returns> - public static string GetLivePlaylistText(string path, int segmentLength) + public static string GetLivePlaylistText(string path, StreamState state) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var reader = new StreamReader(stream); var text = reader.ReadToEnd(); - text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture); - - var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture); + 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); - text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); - // text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase); + // Replace fMP4 init file URI. + text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture); + } return text; } diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index f06f038ab5..efce11f8a7 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -122,49 +122,6 @@ namespace Jellyfin.Api.Helpers return session; } - /// <summary> - /// Get Guid array from string. - /// </summary> - /// <param name="value">String value.</param> - /// <returns>Guid array.</returns> - internal static Guid[] GetGuids(string? value) - { - if (value == null) - { - return Array.Empty<Guid>(); - } - - return Split(value, ',', true) - .Select(i => new Guid(i)) - .ToArray(); - } - - /// <summary> - /// Gets the item fields. - /// </summary> - /// <param name="fields">The fields string.</param> - /// <returns>IEnumerable{ItemFields}.</returns> - internal static ItemFields[] GetItemFields(string? fields) - { - if (string.IsNullOrEmpty(fields)) - { - return Array.Empty<ItemFields>(); - } - - return Split(fields, ',', true) - .Select(v => - { - if (Enum.TryParse(v, true, out ItemFields value)) - { - return (ItemFields?)value; - } - - return null; - }).Where(i => i.HasValue) - .Select(i => i!.Value) - .ToArray(); - } - internal static QueryResult<BaseItemDto> CreateQueryResult( QueryResult<(BaseItem, ItemCounts)> result, DtoOptions dtoOptions, diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 0566f4c4dd..c6d844c4fe 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -169,7 +169,9 @@ namespace Jellyfin.Api.Helpers state.DirectStreamProvider = liveStreamInfo.Item2; } - encodingHelper.AttachMediaSourceInfo(state, mediaSource, url); + var encodingOptions = serverConfigurationManager.GetEncodingOptions(); + + encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url); string? containerInternal = Path.GetExtension(state.RequestedUrl); @@ -187,7 +189,7 @@ namespace Jellyfin.Api.Helpers state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); - state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, state.AudioStream); + state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream); state.OutputAudioCodec = streamingRequest.AudioCodec; @@ -200,20 +202,41 @@ namespace Jellyfin.Api.Helpers encodingHelper.TryStreamCopy(state); - if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) + if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue) { - var resolution = ResolutionNormalizer.Normalize( - state.VideoStream?.BitRate, - state.VideoStream?.Width, - state.VideoStream?.Height, - state.OutputVideoBitrate.Value, - state.VideoStream?.Codec, - state.OutputVideoCodec, - state.VideoRequest.MaxWidth, - state.VideoRequest.MaxHeight); - - state.VideoRequest.MaxWidth = resolution.MaxWidth; - state.VideoRequest.MaxHeight = resolution.MaxHeight; + var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue + && !state.VideoRequest.Height.HasValue + && !state.VideoRequest.MaxWidth.HasValue + && !state.VideoRequest.MaxHeight.HasValue; + + if (isVideoResolutionNotRequested + && 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 + { + var resolution = ResolutionNormalizer.Normalize( + state.VideoStream?.BitRate, + state.VideoStream?.Width, + state.VideoStream?.Height, + state.OutputVideoBitrate.Value, + state.VideoStream?.Codec, + state.OutputVideoCodec, + state.VideoRequest.MaxWidth, + state.VideoRequest.MaxHeight); + + state.VideoRequest.MaxWidth = resolution.MaxWidth; + state.VideoRequest.MaxHeight = resolution.MaxHeight; + } } } diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 168dc27a83..240d132b15 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -12,7 +12,6 @@ using Jellyfin.Api.Models.PlaybackDtos; using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -46,7 +45,6 @@ namespace Jellyfin.Api.Helpers private readonly IAuthorizationContext _authorizationContext; private readonly EncodingHelper _encodingHelper; private readonly IFileSystem _fileSystem; - private readonly IIsoManager _isoManager; private readonly ILogger<TranscodingJobHelper> _logger; private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; @@ -64,7 +62,6 @@ namespace Jellyfin.Api.Helpers /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> /// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> - /// <param name="isoManager">Instance of the <see cref="IIsoManager"/> interface.</param> /// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param> /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> @@ -76,7 +73,6 @@ namespace Jellyfin.Api.Helpers IServerConfigurationManager serverConfigurationManager, ISessionManager sessionManager, IAuthorizationContext authorizationContext, - IIsoManager isoManager, ISubtitleEncoder subtitleEncoder, IConfiguration configuration, ILoggerFactory loggerFactory) @@ -88,7 +84,6 @@ namespace Jellyfin.Api.Helpers _serverConfigurationManager = serverConfigurationManager; _sessionManager = sessionManager; _authorizationContext = authorizationContext; - _isoManager = isoManager; _loggerFactory = loggerFactory; _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration); @@ -145,7 +140,7 @@ namespace Jellyfin.Api.Helpers lock (_activeTranscodingJobs) { // This is really only needed for HLS. - // Progressive streams can stop on their own reliably + // Progressive streams can stop on their own reliably. jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); } @@ -241,7 +236,7 @@ namespace Jellyfin.Api.Helpers lock (_activeTranscodingJobs) { // This is really only needed for HLS. - // Progressive streams can stop on their own reliably + // Progressive streams can stop on their own reliably. jobs.AddRange(_activeTranscodingJobs.Where(killJob)); } @@ -304,10 +299,10 @@ namespace Jellyfin.Api.Helpers process!.StandardInput.WriteLine("q"); - // Need to wait because killing is asynchronous + // Need to wait because killing is asynchronous. if (!process.WaitForExit(5000)) { - _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path); + _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path); process.Kill(); } } @@ -470,11 +465,11 @@ namespace Jellyfin.Api.Helpers } /// <summary> - /// Starts the FFMPEG. + /// 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="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> @@ -501,13 +496,13 @@ namespace Jellyfin.Api.Helpers { this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); - throw new ArgumentException("User does not have access to video transcoding"); + throw new ArgumentException("User does not have access to video transcoding."); } } if (string.IsNullOrEmpty(_mediaEncoder.EncoderPath)) { - throw new ArgumentException("FFMPEG path not set."); + throw new ArgumentException("FFmpeg path not set."); } var process = new Process @@ -544,18 +539,20 @@ namespace Jellyfin.Api.Helpers var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; _logger.LogInformation(commandLineLogMessage); - var logFilePrefix = "ffmpeg-transcode"; + var logFilePrefix = "FFmpeg.Transcode-"; if (state.VideoRequest != null && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) - ? "ffmpeg-remux" - : "ffmpeg-directstream"; + ? "FFmpeg.Remux-" + : "FFmpeg.DirectStream-"; } - var logFilePath = Path.Combine(_serverConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt"); + var logFilePath = Path.Combine( + _serverConfigurationManager.ApplicationPaths.LogDirectoryPath, + $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log"); - // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + // 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, true); var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); @@ -569,20 +566,20 @@ namespace Jellyfin.Api.Helpers } catch (Exception ex) { - _logger.LogError(ex, "Error starting ffmpeg"); + _logger.LogError(ex, "Error starting FFmpeg"); this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state); throw; } - _logger.LogDebug("Launched ffmpeg process"); + _logger.LogDebug("Launched FFmpeg process"); state.TranscodingJob = transcodingJob; - // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback + // 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); - // Wait for the file to exist before proceeeding + // 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) @@ -748,11 +745,11 @@ namespace Jellyfin.Api.Helpers if (process.ExitCode == 0) { - _logger.LogInformation("FFMpeg exited with code 0"); + _logger.LogInformation("FFmpeg exited with code 0"); } else { - _logger.LogError("FFMpeg exited with code {0}", process.ExitCode); + _logger.LogError("FFmpeg exited with code {0}", process.ExitCode); } process.Dispose(); @@ -760,19 +757,15 @@ namespace Jellyfin.Api.Helpers private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) { - if (state.VideoType == VideoType.Iso && state.IsoType.HasValue && _isoManager.CanMount(state.MediaPath)) - { - state.IsoMount = await _isoManager.Mount(state.MediaPath, cancellationTokenSource.Token).ConfigureAwait(false); - } - 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, liveStreamResponse.MediaSource, state.RequestedUrl); + _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl); if (state.VideoRequest != null) { diff --git a/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs new file mode 100644 index 0000000000..e1cb725f3e --- /dev/null +++ b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs @@ -0,0 +1,49 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using Microsoft.Extensions.Logging; + +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> + /// 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); + } + + /// <inheritdoc /> + public Task BindModelAsync(ModelBindingContext bindingContext) + { + 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)) + { + bindingContext.Result = ModelBindingResult.Success(dateTime); + } + else + { + return _defaultModelBinder.BindModelAsync(bindingContext); + } + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs new file mode 100644 index 0000000000..5d296227e6 --- /dev/null +++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs @@ -0,0 +1,47 @@ +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.ModelBinders +{ + /// <summary> + /// Nullable enum model binder. + /// </summary> + public class NullableEnumModelBinder : IModelBinder + { + 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; + } + + /// <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) + { + try + { + var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue); + bindingContext.Result = ModelBindingResult.Success(convertedValue); + } + catch (FormatException e) + { + _logger.LogWarning(e, "Error converting value."); + } + } + + return Task.CompletedTask; + } + } +}
\ No newline at end of file diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs new file mode 100644 index 0000000000..bc12ad05da --- /dev/null +++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.ModelBinders +{ + /// <summary> + /// Nullable enum model binder provider. + /// </summary> + public class NullableEnumModelBinderProvider : IModelBinderProvider + { + /// <inheritdoc /> + public IModelBinder? GetBinder(ModelBinderProviderContext context) + { + var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType); + if (nullableType == 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); + } + } +}
\ No newline at end of file diff --git a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs new file mode 100644 index 0000000000..a42e0e4da8 --- /dev/null +++ b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; + +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> + /// 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) + { + _logger = logger; + } + + /// <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 > 1) + { + var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter); + bindingContext.Result = ModelBindingResult.Success(typedValues); + } + else + { + var value = valueProviderResult.FirstValue; + + if (value != 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); + } + } + + 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++) + { + try + { + parsedValues[i] = converter.ConvertFromString(values[i].Trim()); + convertedCount++; + } + catch (FormatException e) + { + _logger.LogWarning(e, "Error converting value."); + } + } + + 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/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs index 5ca4408d18..a47ae926c1 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs @@ -16,7 +16,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// <summary> /// Gets or sets the channels to return guide information for. /// </summary> - public string? ChannelIds { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>(); /// <summary> /// Gets or sets optional. Filter by user id. @@ -115,12 +116,14 @@ namespace Jellyfin.Api.Models.LiveTvDtos /// <summary> /// Gets or sets the genres to return guide information for. /// </summary> - public string? Genres { get; set; } + [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> - public string? GenreIds { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>(); /// <summary> /// Gets or sets include image information in output. diff --git a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs new file mode 100644 index 0000000000..2cfdba507e --- /dev/null +++ b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs @@ -0,0 +1,86 @@ +using System; +using MediaBrowser.Model.Dlna; + +namespace Jellyfin.Api.Models.MediaInfoDtos +{ + /// <summary> + /// Plabyback info dto. + /// </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; } + } +}
\ No newline at end of file diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs index 0d67c86f71..d0d6889fc8 100644 --- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using MediaBrowser.Common.Json.Converters; namespace Jellyfin.Api.Models.PlaylistDtos { @@ -15,7 +18,8 @@ namespace Jellyfin.Api.Models.PlaylistDtos /// <summary> /// Gets or sets item ids to add to the playlist. /// </summary> - public string? Ids { get; set; } + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>(); /// <summary> /// Gets or sets the user id. diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs new file mode 100644 index 0000000000..e58095536a --- /dev/null +++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using MediaBrowser.Common.Json.Converters; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Session; + +namespace Jellyfin.Api.Models.SessionDtos +{ + /// <summary> + /// Client capabilities dto. + /// </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>(); + + /// <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 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 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 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 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() + { + return new ClientCapabilities + { + PlayableMediaTypes = PlayableMediaTypes, + SupportedCommands = SupportedCommands, + SupportsMediaControl = SupportsMediaControl, + SupportsContentUploading = SupportsContentUploading, + MessageCallbackUrl = MessageCallbackUrl, + SupportsPersistentIdentifier = SupportsPersistentIdentifier, + SupportsSync = SupportsSync, + DeviceProfile = DeviceProfile, + AppStoreUrl = AppStoreUrl, + IconUrl = IconUrl + }; + } + } +}
\ No newline at end of file diff --git a/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs new file mode 100644 index 0000000000..479c440840 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs @@ -0,0 +1,42 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class BufferRequestDto. + /// </summary> + public class BufferRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="BufferRequestDto"/> class. + /// </summary> + public BufferRequestDto() + { + 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 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 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 new file mode 100644 index 0000000000..4c30b7be43 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class IgnoreWaitRequestDto. + /// </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; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs new file mode 100644 index 0000000000..ed97b8d6a5 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs @@ -0,0 +1,16 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class JoinGroupRequestDto. + /// </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; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs new file mode 100644 index 0000000000..3af25f3e3e --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs @@ -0,0 +1,30 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class MovePlaylistItemRequestDto. + /// </summary> + public class MovePlaylistItemRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="MovePlaylistItemRequestDto"/> class. + /// </summary> + public MovePlaylistItemRequestDto() + { + 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 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 new file mode 100644 index 0000000000..441d7be367 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class NewGroupRequestDto. + /// </summary> + public class 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; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs new file mode 100644 index 0000000000..f59a93f13d --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class NextItemRequestDto. + /// </summary> + public class 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; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs new file mode 100644 index 0000000000..c4ac068565 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class PingRequestDto. + /// </summary> + public class PingRequestDto + { + /// <summary> + /// Gets or sets the ping time. + /// </summary> + /// <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 new file mode 100644 index 0000000000..844388cd99 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class PlayRequestDto. + /// </summary> + public class PlayRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="PlayRequestDto"/> class. + /// </summary> + public PlayRequestDto() + { + 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 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; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs new file mode 100644 index 0000000000..7fd4a49be2 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class PreviousItemRequestDto. + /// </summary> + public class 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; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs new file mode 100644 index 0000000000..2b187f443f --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.SyncPlay; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class QueueRequestDto. + /// </summary> + public class QueueRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="QueueRequestDto"/> class. + /// </summary> + public QueueRequestDto() + { + 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 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 new file mode 100644 index 0000000000..d9c193016a --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs @@ -0,0 +1,42 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class ReadyRequest. + /// </summary> + public class ReadyRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="ReadyRequestDto"/> class. + /// </summary> + public ReadyRequestDto() + { + 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 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 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 new file mode 100644 index 0000000000..e9b2b2cb37 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class RemoveFromPlaylistRequestDto. + /// </summary> + public class RemoveFromPlaylistRequestDto + { + /// <summary> + /// Initializes a new instance of the <see cref="RemoveFromPlaylistRequestDto"/> class. + /// </summary> + public RemoveFromPlaylistRequestDto() + { + PlaylistItemIds = Array.Empty<Guid>(); + } + + /// <summary> + /// Gets or sets the playlist identifiers ot the items. + /// </summary> + /// <value>The playlist identifiers ot the items.</value> + public IReadOnlyList<Guid> PlaylistItemIds { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs new file mode 100644 index 0000000000..b9af0be7ff --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class SeekRequestDto. + /// </summary> + public class SeekRequestDto + { + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <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 new file mode 100644 index 0000000000..b937679fc1 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class SetPlaylistItemRequestDto. + /// </summary> + public class 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; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs new file mode 100644 index 0000000000..e748fc3e0f --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Model.SyncPlay; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class SetRepeatModeRequestDto. + /// </summary> + public class SetRepeatModeRequestDto + { + /// <summary> + /// Gets or sets the repeat mode. + /// </summary> + /// <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 new file mode 100644 index 0000000000..0e427f4a4d --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Model.SyncPlay; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// <summary> + /// Class SetShuffleModeRequestDto. + /// </summary> + public class SetShuffleModeRequestDto + { + /// <summary> + /// Gets or sets the shuffle mode. + /// </summary> + /// <value>The shuffle mode.</value> + public GroupShuffleMode Mode { get; set; } + } +} diff --git a/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs b/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs deleted file mode 100644 index db55dc34b5..0000000000 --- a/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -using MediaBrowser.Model.Dlna; - -namespace Jellyfin.Api.Models.VideoDtos -{ - /// <summary> - /// Device profile dto. - /// </summary> - public class DeviceProfileDto - { - /// <summary> - /// Gets or sets device profile. - /// </summary> - public DeviceProfile? DeviceProfile { get; set; } - } -} diff --git a/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs b/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs deleted file mode 100644 index 315b473293..0000000000 --- a/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.ComponentModel; -using System.Globalization; - -namespace Jellyfin.Api.TypeConverters -{ - /// <summary> - /// Custom datetime parser. - /// </summary> - public class DateTimeTypeConverter : TypeConverter - { - /// <inheritdoc /> - public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) - { - if (sourceType == typeof(string)) - { - return true; - } - - return base.CanConvertFrom(context, sourceType); - } - - /// <inheritdoc /> - public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) - { - if (value is string dateString) - { - // Mark Played Item. - if (DateTime.TryParseExact(dateString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime)) - { - return dateTime; - } - - // Get Activity Logs. - if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out dateTime)) - { - return dateTime; - } - } - - return base.ConvertFrom(context, culture, value); - } - } -} diff --git a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs index ce54651166..288e03fcff 100644 --- a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs @@ -58,7 +58,7 @@ namespace Jellyfin.Api.WebSocketListeners private void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) { - SendData(true); + SendData(true).GetAwaiter().GetResult(); } } } |
