diff options
Diffstat (limited to 'Jellyfin.Api')
43 files changed, 1097 insertions, 446 deletions
diff --git a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs index 965b7e7e6..e425000cd 100644 --- a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs +++ b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs @@ -1,10 +1,7 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; -using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy @@ -15,61 +12,44 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy public class FirstTimeSetupHandler : AuthorizationHandler<FirstTimeSetupRequirement> { private readonly IConfigurationManager _configurationManager; - private readonly IUserManager _userManager; /// <summary> /// Initializes a new instance of the <see cref="FirstTimeSetupHandler" /> class. /// </summary> /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> - /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> - public FirstTimeSetupHandler( - IConfigurationManager configurationManager, - IUserManager userManager) + public FirstTimeSetupHandler(IConfigurationManager configurationManager) { _configurationManager = configurationManager; - _userManager = userManager; } /// <inheritdoc /> protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupRequirement requirement) { + // Succeed if the startup wizard / first time setup is not complete if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) { context.Succeed(requirement); - return Task.CompletedTask; } - var contextUser = context.User; - if (requirement.RequireAdmin && !contextUser.IsInRole(UserRoles.Administrator)) - { - context.Fail(); - return Task.CompletedTask; - } - - var userId = contextUser.GetUserId(); - if (userId.IsEmpty()) - { - context.Fail(); - return Task.CompletedTask; - } - - if (!requirement.ValidateParentalSchedule) + // Succeed if user is admin + else if (context.User.IsInRole(UserRoles.Administrator)) { context.Succeed(requirement); - return Task.CompletedTask; } - var user = _userManager.GetUserById(userId); - if (user is null) + // Fail if admin is required and user is not admin + else if (requirement.RequireAdmin) { - throw new ResourceNotFoundException(); + context.Fail(); } - if (user.IsParentalScheduleAllowed()) + // Succeed if admin is not required and user is not guest + else if (context.User.IsInRole(UserRoles.User)) { context.Succeed(requirement); } + // Any user-specific checks are handled in the DefaultAuthorizationHandler. return Task.CompletedTask; } } diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs index 72be55513..8954c8ef5 100644 --- a/Jellyfin.Api/Controllers/AudioController.cs +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -83,6 +83,7 @@ public class AudioController : BaseJellyfinApiController /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <response code="200">Audio stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("{itemId}/stream", Name = "GetAudioStream")] @@ -138,7 +139,8 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string>? streamOptions) + [FromQuery] Dictionary<string, string>? streamOptions, + [FromQuery] bool enableAudioVbrEncoding = true) { StreamingRequestDto streamingRequest = new StreamingRequestDto { @@ -189,7 +191,8 @@ public class AudioController : BaseJellyfinApiController AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Static, - StreamOptions = streamOptions + StreamOptions = streamOptions, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); @@ -247,6 +250,7 @@ public class AudioController : BaseJellyfinApiController /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <response code="200">Audio stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")] @@ -302,7 +306,8 @@ public class AudioController : BaseJellyfinApiController [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string>? streamOptions) + [FromQuery] Dictionary<string, string>? streamOptions, + [FromQuery] bool enableAudioVbrEncoding = true) { StreamingRequestDto streamingRequest = new StreamingRequestDto { @@ -353,7 +358,8 @@ public class AudioController : BaseJellyfinApiController AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Static, - StreamOptions = streamOptions + StreamOptions = streamOptions, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs index 076084c7a..ee912a9be 100644 --- a/Jellyfin.Api/Controllers/DashboardController.cs +++ b/Jellyfin.Api/Controllers/DashboardController.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net.Mime; using Jellyfin.Api.Attributes; using Jellyfin.Api.Models; +using MediaBrowser.Common.Api; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Net; using MediaBrowser.Model.Plugins; @@ -45,9 +46,9 @@ public class DashboardController : BaseJellyfinApiController /// <response code="404">Server still loading.</response> /// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns> [HttpGet("web/ConfigurationPages")] + [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize] public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages( [FromQuery] bool? enableInMainMenu) { diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 1cad66326..6d94d96f3 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -194,7 +194,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase))) { - if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type)) + if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out _)) { _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]); displayPreferences.CustomPrefs.Remove(key); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 49fc2f3d7..329dd2c4c 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -156,6 +156,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="maxWidth">Optional. The max width.</param> /// <param name="maxHeight">Optional. The max height.</param> /// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <response code="200">Hls live stream retrieved.</response> /// <returns>A <see cref="FileResult"/> containing the hls file.</returns> [HttpGet("Videos/{itemId}/live.m3u8")] @@ -213,7 +214,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] Dictionary<string, string> streamOptions, [FromQuery] int? maxWidth, [FromQuery] int? maxHeight, - [FromQuery] bool? enableSubtitlesInManifest) + [FromQuery] bool? enableSubtitlesInManifest, + [FromQuery] bool enableAudioVbrEncoding = true) { VideoRequestDto streamingRequest = new VideoRequestDto { @@ -267,7 +269,8 @@ public class DynamicHlsController : BaseJellyfinApiController StreamOptions = streamOptions, MaxHeight = maxHeight, MaxWidth = maxWidth, - EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true + EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; // CTS lifecycle is managed internally. @@ -393,6 +396,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="streamOptions">Optional. The streaming options.</param> /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> /// <param name="enableTrickplay">Enable trickplay image playlists being added to master playlist.</param> + /// <param name="enableAudioVbrEncoding">Whether to enable Audio Encoding.</param> /// <response code="200">Video stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> [HttpGet("Videos/{itemId}/master.m3u8")] @@ -451,7 +455,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions, [FromQuery] bool enableAdaptiveBitrateStreaming = true, - [FromQuery] bool enableTrickplay = true) + [FromQuery] bool enableTrickplay = true, + [FromQuery] bool enableAudioVbrEncoding = true) { var streamingRequest = new HlsVideoRequestDto { @@ -505,7 +510,8 @@ public class DynamicHlsController : BaseJellyfinApiController Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming, - EnableTrickplay = enableTrickplay + EnableTrickplay = enableTrickplay, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); @@ -564,6 +570,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> /// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <response code="200">Audio stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the playlist file.</returns> [HttpGet("Audio/{itemId}/master.m3u8")] @@ -620,7 +627,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, [FromQuery] Dictionary<string, string> streamOptions, - [FromQuery] bool enableAdaptiveBitrateStreaming = true) + [FromQuery] bool enableAdaptiveBitrateStreaming = true, + [FromQuery] bool enableAudioVbrEncoding = true) { var streamingRequest = new HlsAudioRequestDto { @@ -671,7 +679,8 @@ public class DynamicHlsController : BaseJellyfinApiController VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, StreamOptions = streamOptions, - EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming + EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false); @@ -730,6 +739,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <response code="200">Video stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("Videos/{itemId}/main.m3u8")] @@ -785,7 +795,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAudioVbrEncoding = true) { using var cancellationTokenSource = new CancellationTokenSource(); var streamingRequest = new VideoRequestDto @@ -838,7 +849,8 @@ public class DynamicHlsController : BaseJellyfinApiController AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions + StreamOptions = streamOptions, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) @@ -897,6 +909,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <response code="200">Audio stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("Audio/{itemId}/main.m3u8")] @@ -951,7 +964,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAudioVbrEncoding = true) { using var cancellationTokenSource = new CancellationTokenSource(); var streamingRequest = new StreamingRequestDto @@ -1002,7 +1016,8 @@ public class DynamicHlsController : BaseJellyfinApiController AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions + StreamOptions = streamOptions, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource) @@ -1067,6 +1082,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <response code="200">Video stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] @@ -1128,7 +1144,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAudioVbrEncoding = true) { var streamingRequest = new VideoRequestDto { @@ -1183,7 +1200,8 @@ public class DynamicHlsController : BaseJellyfinApiController AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions + StreamOptions = streamOptions, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; return await GetDynamicSegment(streamingRequest, segmentId) @@ -1247,6 +1265,7 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <response code="200">Video stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")] @@ -1307,7 +1326,8 @@ public class DynamicHlsController : BaseJellyfinApiController [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAudioVbrEncoding = true) { var streamingRequest = new StreamingRequestDto { @@ -1360,7 +1380,8 @@ public class DynamicHlsController : BaseJellyfinApiController AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions + StreamOptions = streamOptions, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; return await GetDynamicSegment(streamingRequest, segmentId) @@ -1481,7 +1502,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (currentTranscodingIndex.HasValue) { - DeleteLastFile(playlistPath, segmentExtension, 0); + await DeleteLastFile(playlistPath, segmentExtension, 0).ConfigureAwait(false); } streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; @@ -1671,8 +1692,8 @@ public class DynamicHlsController : BaseJellyfinApiController if (audioBitrate.HasValue && !EncodingHelper.LosslessAudioCodecs.Contains(state.ActualOutputAudioCodec, StringComparison.OrdinalIgnoreCase)) { - var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, audioBitrate.Value / (audioChannels ?? 2)); - if (_encodingOptions.EnableAudioVbr && vbrParam is not null) + var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, audioBitrate.Value, audioChannels ?? 2); + if (_encodingOptions.EnableAudioVbr && state.EnableAudioVbrEncoding && vbrParam is not null) { audioTranscodeParams += vbrParam; } @@ -1712,12 +1733,11 @@ public class DynamicHlsController : BaseJellyfinApiController var channels = state.OutputAudioChannels; + var useDownMixAlgorithm = state.AudioStream.Channels is 6 && _encodingOptions.DownMixStereoAlgorithm != DownMixStereoAlgorithms.None; + if (channels.HasValue && (channels.Value != 2 - || (state.AudioStream is not null - && state.AudioStream.Channels.HasValue - && state.AudioStream.Channels.Value > 5 - && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))) + || (state.AudioStream?.Channels != null && !useDownMixAlgorithm))) { args += " -ac " + channels.Value; } @@ -1725,8 +1745,8 @@ public class DynamicHlsController : BaseJellyfinApiController var bitrate = state.OutputAudioBitrate; if (bitrate.HasValue && !EncodingHelper.LosslessAudioCodecs.Contains(actualOutputAudioCodec, StringComparison.OrdinalIgnoreCase)) { - var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, bitrate.Value / (channels ?? 2)); - if (_encodingOptions.EnableAudioVbr && vbrParam is not null) + var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, bitrate.Value, channels ?? 2); + if (_encodingOptions.EnableAudioVbr && state.EnableAudioVbrEncoding && vbrParam is not null) { args += vbrParam; } @@ -1740,6 +1760,12 @@ public class DynamicHlsController : BaseJellyfinApiController { args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } + else if (state.AudioStream?.CodecTag is not null && state.AudioStream.CodecTag.Equals("ac-4", StringComparison.Ordinal)) + { + // ac-4 audio tends to hava a super weird sample rate that will fail most encoders + // force resample it to 48KHz + args += " -ar 48000"; + } args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions); @@ -2010,17 +2036,19 @@ public class DynamicHlsController : BaseJellyfinApiController } } - private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + private Task DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) { var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem); - if (file is not null) + if (file is null) { - DeleteFile(file.FullName, retryCount); + return Task.CompletedTask; } + + return DeleteFile(file.FullName, retryCount); } - private void DeleteFile(string path, int retryCount) + private async Task DeleteFile(string path, int retryCount) { if (retryCount >= 5) { @@ -2037,9 +2065,8 @@ public class DynamicHlsController : BaseJellyfinApiController { _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); - var task = Task.Delay(100); - task.Wait(); - DeleteFile(path, retryCount + 1); + await Task.Delay(100).ConfigureAwait(false); + await DeleteFile(path, retryCount + 1).ConfigureAwait(false); } catch (Exception ex) { diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index d6e043e6a..4abca3271 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -162,7 +162,7 @@ public class FilterController : BaseJellyfinApiController } else if (parentId.HasValue) { - parentItem = _libraryManager.GetItemById(parentId.Value); + parentItem = _libraryManager.GetItemById<BaseItem>(parentId.Value); } var filters = new QueryFilters(); diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 6b38fa7d3..8e8accab3 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -90,6 +90,7 @@ public class ImageController : BaseJellyfinApiController /// <param name="userId">User Id.</param> /// <response code="204">Image updated.</response> /// <response code="403">User does not have permission to delete the image.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("UserImage")] [Authorize] @@ -97,6 +98,7 @@ public class ImageController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> PostUserImage( [FromQuery] Guid? userId) { @@ -289,7 +291,7 @@ public class ImageController : BaseJellyfinApiController [FromRoute, Required] ImageType imageType, [FromQuery] int? imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -317,7 +319,7 @@ public class ImageController : BaseJellyfinApiController [FromRoute, Required] ImageType imageType, [FromRoute] int imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -346,7 +348,7 @@ public class ImageController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] ImageType imageType) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -390,7 +392,7 @@ public class ImageController : BaseJellyfinApiController [FromRoute, Required] ImageType imageType, [FromRoute] int imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -433,7 +435,7 @@ public class ImageController : BaseJellyfinApiController [FromRoute, Required] int imageIndex, [FromQuery, Required] int newIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -456,7 +458,7 @@ public class ImageController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -559,7 +561,7 @@ public class ImageController : BaseJellyfinApiController [FromQuery] string? foregroundLayer, [FromQuery] int? imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -637,7 +639,7 @@ public class ImageController : BaseJellyfinApiController [FromQuery] string? backgroundColor, [FromQuery] string? foregroundLayer) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -715,7 +717,7 @@ public class ImageController : BaseJellyfinApiController [FromQuery] string? foregroundLayer, [FromRoute, Required] int imageIndex) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index 3cf485299..dcbacf1d7 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -62,9 +62,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Songs/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, @@ -75,11 +77,16 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -99,9 +106,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Albums/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, @@ -112,15 +121,20 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var album = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } @@ -136,9 +150,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Playlists/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, @@ -149,15 +165,20 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var playlist = (Playlist)_libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<Playlist>(itemId, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); - var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions); + var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions); return GetResult(items, user, limit, dtoOptions); } @@ -209,9 +230,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Artists/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, @@ -222,11 +245,16 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -246,9 +274,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Items/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, @@ -259,11 +289,16 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -283,9 +318,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("Artists/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [Obsolete("Use GetInstantMixFromArtists")] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2( [FromQuery, Required] Guid id, @@ -320,9 +357,11 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> [HttpGet("MusicGenres/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById( [FromQuery, Required] Guid id, [FromQuery] Guid? userId, @@ -333,11 +372,16 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(id, user); + if (item is null) + { + return NotFound(); + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index e3aee1bf7..d009f80a9 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -4,6 +4,8 @@ using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -64,7 +66,7 @@ public class ItemLookupController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -234,6 +236,7 @@ public class ItemLookupController : BaseJellyfinApiController /// <param name="searchResult">The remote search result.</param> /// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param> /// <response code="204">Item metadata refreshed.</response> + /// <response code="404">Item not found.</response> /// <returns> /// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results. /// The task result contains an <see cref="NoContentResult"/>. @@ -241,12 +244,18 @@ public class ItemLookupController : BaseJellyfinApiController [HttpPost("Items/RemoteSearch/Apply/{itemId}")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> ApplySearchCriteria( [FromRoute, Required] Guid itemId, [FromBody, Required] RemoteSearchResult searchResult, [FromQuery] bool replaceAllImages = true) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } + _logger.LogInformation( "Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}", item.Id, diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index 0a8522e1c..d7a8c37c4 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -2,7 +2,10 @@ using System; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using MediaBrowser.Common.Api; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; @@ -61,7 +64,7 @@ public class ItemRefreshController : BaseJellyfinApiController [FromQuery] bool replaceAllMetadata = false, [FromQuery] bool replaceAllImages = false) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -77,7 +80,8 @@ public class ItemRefreshController : BaseJellyfinApiController || imageRefreshMode == MetadataRefreshMode.FullRefresh || replaceAllImages || replaceAllMetadata, - IsAutomated = false + IsAutomated = false, + RemoveOldMetadata = replaceAllMetadata }; _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 9800248c6..4001a6add 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -5,6 +5,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Data.Enums; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Configuration; @@ -72,7 +74,7 @@ public class ItemUpdateController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -145,7 +147,11 @@ public class ItemUpdateController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } var info = new MetadataEditorInfo { @@ -197,7 +203,7 @@ public class ItemUpdateController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -258,7 +264,7 @@ public class ItemUpdateController : BaseJellyfinApiController if (request.Studios is not null) { - item.Studios = request.Studios.Select(x => x.Name).ToArray(); + item.Studios = Array.ConvertAll(request.Studios, x => x.Name); } if (request.DateCreated.HasValue) @@ -282,19 +288,37 @@ public class ItemUpdateController : BaseJellyfinApiController if (item is Series rseries) { - foreach (Season season in rseries.Children) + foreach (var season in rseries.Children.OfType<Season>()) { - season.OfficialRating = request.OfficialRating; + if (!season.LockedFields.Contains(MetadataField.OfficialRating)) + { + season.OfficialRating = request.OfficialRating; + } + season.CustomRating = request.CustomRating; - season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + + if (!season.LockedFields.Contains(MetadataField.Tags)) + { + season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + } + season.OnMetadataChanged(); await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - foreach (Episode ep in season.Children) + foreach (var ep in season.Children.OfType<Episode>()) { - ep.OfficialRating = request.OfficialRating; + if (!ep.LockedFields.Contains(MetadataField.OfficialRating)) + { + ep.OfficialRating = request.OfficialRating; + } + ep.CustomRating = request.CustomRating; - ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + + if (!ep.LockedFields.Contains(MetadataField.Tags)) + { + ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + } + ep.OnMetadataChanged(); await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } @@ -302,11 +326,20 @@ public class ItemUpdateController : BaseJellyfinApiController } else if (item is Season season) { - foreach (Episode ep in season.Children) + foreach (var ep in season.Children.OfType<Episode>()) { - ep.OfficialRating = request.OfficialRating; + if (!ep.LockedFields.Contains(MetadataField.OfficialRating)) + { + ep.OfficialRating = request.OfficialRating; + } + ep.CustomRating = request.CustomRating; - ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + + if (!ep.LockedFields.Contains(MetadataField.Tags)) + { + ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + } + ep.OnMetadataChanged(); await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } @@ -315,9 +348,18 @@ public class ItemUpdateController : BaseJellyfinApiController { foreach (BaseItem track in album.Children) { - track.OfficialRating = request.OfficialRating; + if (!track.LockedFields.Contains(MetadataField.OfficialRating)) + { + track.OfficialRating = request.OfficialRating; + } + track.CustomRating = request.CustomRating; - track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + + if (!track.LockedFields.Contains(MetadataField.Tags)) + { + track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + } + track.OnMetadataChanged(); await track.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } @@ -373,10 +415,7 @@ public class ItemUpdateController : BaseJellyfinApiController { if (item is IHasAlbumArtist hasAlbumArtists) { - hasAlbumArtists.AlbumArtists = request - .AlbumArtists - .Select(i => i.Name) - .ToArray(); + hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name); } } @@ -384,10 +423,7 @@ public class ItemUpdateController : BaseJellyfinApiController { if (item is IHasArtist hasArtists) { - hasArtists.Artists = request - .ArtistItems - .Select(i => i.Name) - .ToArray(); + hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name); } } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 26ae1a820..d33634412 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -76,6 +76,7 @@ public class ItemsController : BaseJellyfinApiController /// <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="indexNumber">Optional filter by index number.</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> @@ -165,6 +166,7 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] bool? hasSpecialFeature, [FromQuery] bool? hasTrailer, [FromQuery] Guid? adjacentTo, + [FromQuery] int? indexNumber, [FromQuery] int? parentIndexNumber, [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, @@ -246,9 +248,9 @@ public class ItemsController : BaseJellyfinApiController var isApiKey = User.GetIsApiKey(); // if api key is used (auth.IsApiKey == true), then `user` will be null throughout this method userId = RequestHelpers.GetUserId(User, userId); - var user = !isApiKey && !userId.IsNullOrEmpty() - ? _userManager.GetUserById(userId.Value) ?? throw new ResourceNotFoundException() - : null; + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value) ?? throw new ResourceNotFoundException(); // beyond this point, we're either using an api key or we have a valid user if (!isApiKey && user is null) @@ -256,6 +258,13 @@ public class ItemsController : BaseJellyfinApiController return BadRequest("userId is required"); } + if (user is not null + && user.GetPreference(PreferenceKind.AllowedTags).Length != 0 + && !fields.Contains(ItemFields.Tags)) + { + fields = [..fields, ItemFields.Tags]; + } + var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); @@ -359,6 +368,7 @@ public class ItemsController : BaseJellyfinApiController MinCommunityRating = minCommunityRating, MinCriticRating = minCriticRating, ParentId = parentId ?? Guid.Empty, + IndexNumber = indexNumber, ParentIndexNumber = parentIndexNumber, EnableTotalRecordCount = enableTotalRecordCount, ExcludeItemIds = excludeItemIds, @@ -710,6 +720,7 @@ public class ItemsController : BaseJellyfinApiController hasSpecialFeature, hasTrailer, adjacentTo, + null, parentIndexNumber, hasParentalRating, isHd, @@ -967,9 +978,13 @@ public class ItemsController : BaseJellyfinApiController } var user = _userManager.GetUserById(requestUserId) ?? throw new ResourceNotFoundException(); - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } - return (item == null) ? NotFound() : _userDataRepository.GetUserDataDto(item, user); + return _userDataRepository.GetUserDataDto(item, user); } /// <summary> @@ -1014,8 +1029,8 @@ public class ItemsController : BaseJellyfinApiController } var user = _userManager.GetUserById(requestUserId) ?? throw new ResourceNotFoundException(); - var item = _libraryManager.GetItemById(itemId); - if (item == null) + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) { return NotFound(); } diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 984dc7789..62cb59335 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -102,7 +102,7 @@ public class LibraryController : BaseJellyfinApiController [ProducesFile("video/*", "audio/*")] public ActionResult GetFile([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -131,6 +131,8 @@ public class LibraryController : BaseJellyfinApiController /// <param name="itemId">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <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="sortOrder">Optional. Sort Order - Ascending, Descending.</param> /// <response code="200">Theme songs returned.</response> /// <response code="404">Item not found.</response> /// <returns>The item theme songs.</returns> @@ -141,7 +143,9 @@ public class LibraryController : BaseJellyfinApiController public ActionResult<ThemeMediaResult> GetThemeSongs( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, - [FromQuery] bool inheritFromParent = false) + [FromQuery] bool inheritFromParent = false, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() @@ -152,20 +156,23 @@ public class LibraryController : BaseJellyfinApiController ? (userId.IsNullOrEmpty() ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { - return NotFound("Item not found."); + return NotFound(); } - IEnumerable<BaseItem> themeItems; + sortOrder ??= []; + sortBy ??= []; + var orderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder); + + IReadOnlyList<BaseItem> themeItems; while (true) { - themeItems = item.GetThemeSongs(); + themeItems = item.GetThemeSongs(user, orderBy); - if (themeItems.Any() || !inheritFromParent) + if (themeItems.Count > 0 || !inheritFromParent) { break; } @@ -198,6 +205,8 @@ public class LibraryController : BaseJellyfinApiController /// <param name="itemId">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <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="sortOrder">Optional. Sort Order - Ascending, Descending.</param> /// <response code="200">Theme videos returned.</response> /// <response code="404">Item not found.</response> /// <returns>The item theme videos.</returns> @@ -208,29 +217,33 @@ public class LibraryController : BaseJellyfinApiController public ActionResult<ThemeMediaResult> GetThemeVideos( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, - [FromQuery] bool inheritFromParent = false) + [FromQuery] bool inheritFromParent = false, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null) { userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - var item = itemId.IsEmpty() ? (userId.IsNullOrEmpty() ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { - return NotFound("Item not found."); + return NotFound(); } + sortOrder ??= []; + sortBy ??= []; + var orderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder); + IEnumerable<BaseItem> themeItems; while (true) { - themeItems = item.GetThemeVideos(); + themeItems = item.GetThemeVideos(user, orderBy); if (themeItems.Any() || !inheritFromParent) { @@ -265,6 +278,8 @@ public class LibraryController : BaseJellyfinApiController /// <param name="itemId">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param> + /// <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="sortOrder">Optional. Sort Order - Ascending, Descending.</param> /// <response code="200">Theme songs and videos returned.</response> /// <response code="404">Item not found.</response> /// <returns>The item theme videos.</returns> @@ -274,19 +289,26 @@ public class LibraryController : BaseJellyfinApiController public ActionResult<AllThemeMediaResult> GetThemeMedia( [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, - [FromQuery] bool inheritFromParent = false) + [FromQuery] bool inheritFromParent = false, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null, + [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null) { var themeSongs = GetThemeSongs( itemId, userId, - inheritFromParent); + inheritFromParent, + sortBy, + sortOrder); var themeVideos = GetThemeVideos( itemId, userId, - inheritFromParent); + inheritFromParent, + sortBy, + sortOrder); - if (themeSongs.Result is NotFoundObjectResult || themeVideos.Result is NotFoundObjectResult) + if (themeSongs.Result is StatusCodeResult { StatusCode: StatusCodes.Status404NotFound } + || themeVideos.Result is StatusCodeResult { StatusCode: StatusCodes.Status404NotFound }) { return NotFound(); } @@ -327,6 +349,7 @@ public class LibraryController : BaseJellyfinApiController /// <param name="itemId">The item id.</param> /// <response code="204">Item deleted.</response> /// <response code="401">Unauthorized access.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpDelete("Items/{itemId}")] [Authorize] @@ -335,17 +358,18 @@ public class LibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult DeleteItem(Guid itemId) { - var isApiKey = User.GetIsApiKey(); var userId = User.GetUserId(); - var user = !isApiKey && !userId.IsEmpty() - ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException() - : null; - if (!isApiKey && user is null) + var isApiKey = User.GetIsApiKey(); + var user = userId.IsEmpty() && isApiKey + ? null + : _userManager.GetUserById(userId); + + if (user is null && !isApiKey) { - return Unauthorized("Unauthorized access"); + return NotFound(); } - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); @@ -391,7 +415,7 @@ public class LibraryController : BaseJellyfinApiController foreach (var i in ids) { - var item = _libraryManager.GetItemById(i); + var item = _libraryManager.GetItemById<BaseItem>(i, user); if (item is null) { return NotFound(); @@ -459,20 +483,18 @@ public class LibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) { - var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); - + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { - return NotFound("Item not found"); + return NotFound(); } var baseItemDtos = new List<BaseItemDto>(); - var user = userId.IsNullOrEmpty() - ? null - : _userManager.GetUserById(userId.Value); - var dtoOptions = new DtoOptions().AddClientFields(User); BaseItem? parent = item.GetParent(); @@ -520,7 +542,11 @@ public class LibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden) { - var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList(); + var items = _libraryManager.GetUserRootFolder().Children + .Concat(_libraryManager.RootFolder.VirtualChildren) + .Where(i => _libraryManager.GetLibraryOptions(i).Enabled) + .OrderBy(i => i.SortName) + .ToList(); if (isHidden.HasValue) { @@ -640,14 +666,16 @@ public class LibraryController : BaseJellyfinApiController [ProducesFile("video/*", "audio/*")] public async Task<ActionResult> GetDownload([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var userId = User.GetUserId(); + var user = userId.IsEmpty() + ? null + : _userManager.GetUserById(userId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - var user = _userManager.GetUserById(User.GetUserId()); - if (user is not null) { if (!item.CanDownload(user)) @@ -700,12 +728,14 @@ public class LibraryController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields) { userId = RequestHelpers.GetUserId(User, userId); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); var item = itemId.IsEmpty() - ? (userId.IsNullOrEmpty() + ? (user is null ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); @@ -716,9 +746,6 @@ public class LibraryController : BaseJellyfinApiController return new QueryResult<BaseItemDto>(); } - var user = userId.IsNullOrEmpty() - ? null - : _userManager.GetUserById(userId.Value); var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User); diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 23c430f85..93c2393f3 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -6,6 +6,8 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; using MediaBrowser.Common.Api; @@ -73,7 +75,7 @@ public class LibraryStructureController : BaseJellyfinApiController [HttpPost] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> AddVirtualFolder( - [FromQuery] string? name, + [FromQuery] string name, [FromQuery] CollectionTypeOptions? collectionType, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths, [FromBody] AddVirtualFolderDto? libraryOptionsDto, @@ -83,7 +85,7 @@ public class LibraryStructureController : BaseJellyfinApiController if (paths is not null && paths.Length > 0) { - libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray(); + libraryOptions.PathInfos = Array.ConvertAll(paths, i => new MediaPathInfo(i)); } await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false); @@ -101,7 +103,7 @@ public class LibraryStructureController : BaseJellyfinApiController [HttpDelete] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task<ActionResult> RemoveVirtualFolder( - [FromQuery] string? name, + [FromQuery] string name, [FromQuery] bool refreshLibrary = false) { await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false); @@ -178,7 +180,21 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); + var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); + if (newLib is CollectionFolder folder) + { + foreach (var child in folder.GetPhysicalFolders()) + { + await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); + await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + } + } + else + { + // We don't know if this one can be validated individually, trigger a new validation + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + } } else { @@ -265,18 +281,16 @@ public class LibraryStructureController : BaseJellyfinApiController /// <param name="refreshLibrary">Whether to refresh the library.</param> /// <returns>A <see cref="NoContentResult"/>.</returns> /// <response code="204">Media path removed.</response> - /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception> + /// <exception cref="ArgumentException">The name of the library and path may not be empty.</exception> [HttpDelete("Paths")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult RemoveMediaPath( - [FromQuery] string? name, - [FromQuery] string? path, + [FromQuery] string name, + [FromQuery] string path, [FromQuery] bool refreshLibrary = false) { - if (string.IsNullOrWhiteSpace(name)) - { - throw new ArgumentNullException(nameof(name)); - } + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(path); _libraryMonitor.Stop(); @@ -311,15 +325,21 @@ public class LibraryStructureController : BaseJellyfinApiController /// </summary> /// <param name="request">The library name and options.</param> /// <response code="204">Library updated.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("LibraryOptions")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UpdateLibraryOptions( [FromBody] UpdateLibraryOptionsDto request) { - var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id); + var item = _libraryManager.GetItemById<CollectionFolder>(request.Id); + if (item is null) + { + return NotFound(); + } - collectionFolder.UpdateLibraryOptions(request.LibraryOptions); + item.UpdateLibraryOptions(request.LibraryOptions); return NoContent(); } } diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 7768b3c45..2b26c01f8 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -220,9 +220,11 @@ public class LiveTvController : BaseJellyfinApiController /// <param name="channelId">Channel id.</param> /// <param name="userId">Optional. Attach user data.</param> /// <response code="200">Live tv channel returned.</response> + /// <response code="404">Item not found.</response> /// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns> [HttpGet("Channels/{channelId}")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [Authorize(Policy = Policies.LiveTvAccess)] public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId) { @@ -232,7 +234,12 @@ public class LiveTvController : BaseJellyfinApiController : _userManager.GetUserById(userId.Value); var item = channelId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(channelId); + : _libraryManager.GetItemById<BaseItem>(channelId, user); + + if (item is null) + { + return NotFound(); + } var dtoOptions = new DtoOptions() .AddClientFields(User); @@ -416,9 +423,11 @@ public class LiveTvController : BaseJellyfinApiController /// <param name="recordingId">Recording id.</param> /// <param name="userId">Optional. Attach user data.</param> /// <response code="200">Recording returned.</response> + /// <response code="404">Item not found.</response> /// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns> [HttpGet("Recordings/{recordingId}")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [Authorize(Policy = Policies.LiveTvAccess)] public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId) { @@ -426,7 +435,13 @@ public class LiveTvController : BaseJellyfinApiController var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - var item = recordingId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId); + var item = recordingId.IsEmpty() + ? _libraryManager.GetUserRootFolder() + : _libraryManager.GetItemById<BaseItem>(recordingId, user); + if (item is null) + { + return NotFound(); + } var dtoOptions = new DtoOptions() .AddClientFields(User); @@ -611,7 +626,8 @@ public class LiveTvController : BaseJellyfinApiController { query.IsSeries = true; - if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series) + var series = _libraryManager.GetItemById<Series>(librarySeriesId.Value); + if (series is not null) { query.Name = series.Name; } @@ -665,7 +681,8 @@ public class LiveTvController : BaseJellyfinApiController { query.IsSeries = true; - if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series) + var series = _libraryManager.GetItemById<Series>(body.LibrarySeriesId); + if (series is not null) { query.Name = series.Name; } @@ -779,7 +796,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult DeleteRecording([FromRoute, Required] Guid recordingId) { - var item = _libraryManager.GetItemById(recordingId); + var item = _libraryManager.GetItemById<BaseItem>(recordingId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/LyricsController.cs b/Jellyfin.Api/Controllers/LyricsController.cs index f2b312b47..8eb4cadf8 100644 --- a/Jellyfin.Api/Controllers/LyricsController.cs +++ b/Jellyfin.Api/Controllers/LyricsController.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Controller.Entities.Audio; @@ -66,37 +67,16 @@ public class LyricsController : BaseJellyfinApiController [HttpGet("Audio/{itemId}/Lyrics")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<LyricDto>> GetLyrics([FromRoute, Required] Guid itemId) { - var isApiKey = User.GetIsApiKey(); - var userId = User.GetUserId(); - if (!isApiKey && userId.IsEmpty()) - { - return BadRequest(); - } - - var audio = _libraryManager.GetItemById<Audio>(itemId); - if (audio is null) + var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId()); + if (item is null) { return NotFound(); } - if (!isApiKey) - { - var user = _userManager.GetUserById(userId); - if (user is null) - { - return NotFound(); - } - - // Check the item is visible for the user - if (!audio.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {audio.Name}."); - } - } - - var result = await _lyricManager.GetLyricsAsync(audio, CancellationToken.None).ConfigureAwait(false); + var result = await _lyricManager.GetLyricsAsync(item, CancellationToken.None).ConfigureAwait(false); if (result is not null) { return Ok(result); @@ -124,8 +104,8 @@ public class LyricsController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery, Required] string fileName) { - var audio = _libraryManager.GetItemById<Audio>(itemId); - if (audio is null) + var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId()); + if (item is null) { return NotFound(); } @@ -147,7 +127,7 @@ public class LyricsController : BaseJellyfinApiController { await Request.Body.CopyToAsync(stream).ConfigureAwait(false); var uploadedLyric = await _lyricManager.SaveLyricAsync( - audio, + item, format, stream) .ConfigureAwait(false); @@ -157,7 +137,7 @@ public class LyricsController : BaseJellyfinApiController return BadRequest(); } - _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); return Ok(uploadedLyric); } } @@ -176,13 +156,13 @@ public class LyricsController : BaseJellyfinApiController public async Task<ActionResult> DeleteLyrics( [FromRoute, Required] Guid itemId) { - var audio = _libraryManager.GetItemById<Audio>(itemId); - if (audio is null) + var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId()); + if (item is null) { return NotFound(); } - await _lyricManager.DeleteLyricsAsync(audio).ConfigureAwait(false); + await _lyricManager.DeleteLyricsAsync(item).ConfigureAwait(false); return NoContent(); } @@ -199,13 +179,13 @@ public class LyricsController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<IReadOnlyList<RemoteLyricInfoDto>>> SearchRemoteLyrics([FromRoute, Required] Guid itemId) { - var audio = _libraryManager.GetItemById<Audio>(itemId); - if (audio is null) + var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId()); + if (item is null) { return NotFound(); } - var results = await _lyricManager.SearchLyricsAsync(audio, false, CancellationToken.None).ConfigureAwait(false); + var results = await _lyricManager.SearchLyricsAsync(item, false, CancellationToken.None).ConfigureAwait(false); return Ok(results); } @@ -225,19 +205,19 @@ public class LyricsController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] string lyricId) { - var audio = _libraryManager.GetItemById<Audio>(itemId); - if (audio is null) + var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId()); + if (item is null) { return NotFound(); } - var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(audio, lyricId, CancellationToken.None).ConfigureAwait(false); + var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(item, lyricId, CancellationToken.None).ConfigureAwait(false); if (downloadedLyrics is null) { return NotFound(); } - _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); return Ok(downloadedLyrics); } diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index 742012b71..bc52be184 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -8,8 +8,10 @@ using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.MediaInfoDtos; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.MediaInfo; using Microsoft.AspNetCore.Authorization; @@ -32,6 +34,7 @@ public class MediaInfoController : BaseJellyfinApiController private readonly ILibraryManager _libraryManager; private readonly ILogger<MediaInfoController> _logger; private readonly MediaInfoHelper _mediaInfoHelper; + private readonly IUserManager _userManager; /// <summary> /// Initializes a new instance of the <see cref="MediaInfoController"/> class. @@ -41,18 +44,21 @@ public class MediaInfoController : BaseJellyfinApiController /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param> /// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface..</param> public MediaInfoController( IMediaSourceManager mediaSourceManager, IDeviceManager deviceManager, ILibraryManager libraryManager, ILogger<MediaInfoController> logger, - MediaInfoHelper mediaInfoHelper) + MediaInfoHelper mediaInfoHelper, + IUserManager userManager) { _mediaSourceManager = mediaSourceManager; _deviceManager = deviceManager; _libraryManager = libraryManager; _logger = logger; _mediaInfoHelper = mediaInfoHelper; + _userManager = userManager; } /// <summary> @@ -61,16 +67,24 @@ public class MediaInfoController : BaseJellyfinApiController /// <param name="itemId">The item id.</param> /// <param name="userId">The user id.</param> /// <response code="200">Playback info returned.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns> [HttpGet("Items/{itemId}/PlaybackInfo")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - return await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId) - .ConfigureAwait(false); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + + return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false); } /// <summary> @@ -97,9 +111,11 @@ public class MediaInfoController : BaseJellyfinApiController /// <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> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns> [HttpPost("Items/{itemId}/PlaybackInfo")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo( [FromRoute, Required] Guid itemId, [FromQuery, ParameterObsolete] Guid? userId, @@ -148,9 +164,19 @@ public class MediaInfoController : BaseJellyfinApiController allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true; allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true; + userId = RequestHelpers.GetUserId(User, userId); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + var info = await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId, + item, + user, mediaSourceId, liveStreamId) .ConfigureAwait(false); @@ -163,8 +189,6 @@ public class MediaInfoController : BaseJellyfinApiController if (profile is not null) { // set device specific data - var item = _libraryManager.GetItemById(itemId); - foreach (var mediaSource in info.MediaSources) { _mediaInfoHelper.SetDeviceSpecificData( diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs index c5e940108..274e94ee6 100644 --- a/Jellyfin.Api/Controllers/PackageController.cs +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -18,7 +18,7 @@ namespace Jellyfin.Api.Controllers; /// Package Controller. /// </summary> [Route("")] -[Authorize] +[Authorize(Policy = Policies.RequiresElevation)] public class PackageController : BaseJellyfinApiController { private readonly IInstallationManager _installationManager; @@ -90,7 +90,6 @@ public class PackageController : BaseJellyfinApiController [HttpPost("Packages/Installed/{name}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - [Authorize(Policy = Policies.RequiresElevation)] public async Task<ActionResult> InstallPackage( [FromRoute, Required] string name, [FromQuery] Guid? assemblyGuid, @@ -128,7 +127,6 @@ public class PackageController : BaseJellyfinApiController /// <response code="204">Installation cancelled.</response> /// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns> [HttpDelete("Packages/Installing/{packageId}")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult CancelPackageInstallation( [FromRoute, Required] Guid packageId) @@ -156,7 +154,6 @@ public class PackageController : BaseJellyfinApiController /// <response code="204">Package repositories saved.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Repositories")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos) { diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 0e7c3f155..63d6e1cc3 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -92,29 +92,271 @@ public class PlaylistsController : BaseJellyfinApiController Name = name ?? createPlaylistRequest?.Name, ItemIdList = ids, UserId = userId.Value, - MediaType = mediaType ?? createPlaylistRequest?.MediaType + MediaType = mediaType ?? createPlaylistRequest?.MediaType, + Users = createPlaylistRequest?.Users.ToArray() ?? [], + Public = createPlaylistRequest?.IsPublic }).ConfigureAwait(false); return result; } /// <summary> + /// Updates a playlist. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="updatePlaylistRequest">The <see cref="UpdatePlaylistDto"/> id.</param> + /// <response code="204">Playlist updated.</response> + /// <response code="403">Access forbidden.</response> + /// <response code="404">Playlist not found.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to update a playlist. + /// The task result contains an <see cref="OkResult"/> indicating success. + /// </returns> + [HttpPost("{playlistId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdatePlaylist( + [FromRoute, Required] Guid playlistId, + [FromBody, Required] UpdatePlaylistDto updatePlaylistRequest) + { + var callingUserId = User.GetUserId(); + + var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OwnerUserId.Equals(callingUserId) + || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)); + + if (!isPermitted) + { + return Forbid(); + } + + await _playlistManager.UpdatePlaylist(new PlaylistUpdateRequest + { + UserId = callingUserId, + Id = playlistId, + Name = updatePlaylistRequest.Name, + Ids = updatePlaylistRequest.Ids, + Users = updatePlaylistRequest.Users, + Public = updatePlaylistRequest.IsPublic + }).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Get a playlist's users. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <response code="200">Found shares.</response> + /// <response code="403">Access forbidden.</response> + /// <response code="404">Playlist not found.</response> + /// <returns> + /// A list of <see cref="PlaylistUserPermissions"/> objects. + /// </returns> + [HttpGet("{playlistId}/Users")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<IReadOnlyList<PlaylistUserPermissions>> GetPlaylistUsers( + [FromRoute, Required] Guid playlistId) + { + var userId = User.GetUserId(); + + var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OwnerUserId.Equals(userId); + + return isPermitted ? playlist.Shares.ToList() : Forbid(); + } + + /// <summary> + /// Get a playlist user. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="userId">The user id.</param> + /// <response code="200">User permission found.</response> + /// <response code="403">Access forbidden.</response> + /// <response code="404">Playlist not found.</response> + /// <returns> + /// <see cref="PlaylistUserPermissions"/>. + /// </returns> + [HttpGet("{playlistId}/Users/{userId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult<PlaylistUserPermissions?> GetPlaylistUser( + [FromRoute, Required] Guid playlistId, + [FromRoute, Required] Guid userId) + { + var callingUserId = User.GetUserId(); + + var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + if (playlist.OwnerUserId.Equals(callingUserId)) + { + return new PlaylistUserPermissions(callingUserId, true); + } + + var userPermission = playlist.Shares.FirstOrDefault(s => s.UserId.Equals(userId)); + var isPermitted = playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)) + || userId.Equals(callingUserId); + + if (!isPermitted) + { + return Forbid(); + } + + if (userPermission is not null) + { + return userPermission; + } + + return NotFound("User permissions not found"); + } + + /// <summary> + /// Modify a user of a playlist's users. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="userId">The user id.</param> + /// <param name="updatePlaylistUserRequest">The <see cref="UpdatePlaylistUserDto"/>.</param> + /// <response code="204">User's permissions modified.</response> + /// <response code="403">Access forbidden.</response> + /// <response code="404">Playlist not found.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to modify an user's playlist permissions. + /// The task result contains an <see cref="OkResult"/> indicating success. + /// </returns> + [HttpPost("{playlistId}/Users/{userId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> UpdatePlaylistUser( + [FromRoute, Required] Guid playlistId, + [FromRoute, Required] Guid userId, + [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow), Required] UpdatePlaylistUserDto updatePlaylistUserRequest) + { + var callingUserId = User.GetUserId(); + + var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OwnerUserId.Equals(callingUserId); + + if (!isPermitted) + { + return Forbid(); + } + + await _playlistManager.AddUserToShares(new PlaylistUserUpdateRequest + { + Id = playlistId, + UserId = userId, + CanEdit = updatePlaylistUserRequest.CanEdit + }).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> + /// Remove a user from a playlist's users. + /// </summary> + /// <param name="playlistId">The playlist id.</param> + /// <param name="userId">The user id.</param> + /// <response code="204">User permissions removed from playlist.</response> + /// <response code="401">Unauthorized access.</response> + /// <response code="404">No playlist or user permissions found.</response> + /// <returns> + /// A <see cref="Task" /> that represents the asynchronous operation to delete a user from a playlist's shares. + /// The task result contains an <see cref="OkResult"/> indicating success. + /// </returns> + [HttpDelete("{playlistId}/Users/{userId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> RemoveUserFromPlaylist( + [FromRoute, Required] Guid playlistId, + [FromRoute, Required] Guid userId) + { + var callingUserId = User.GetUserId(); + + var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OwnerUserId.Equals(callingUserId) + || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)); + + if (!isPermitted) + { + return Forbid(); + } + + var share = playlist.Shares.FirstOrDefault(s => s.UserId.Equals(userId)); + if (share is null) + { + return NotFound("User permissions not found"); + } + + await _playlistManager.RemoveUserFromShares(playlistId, callingUserId, share).ConfigureAwait(false); + + return NoContent(); + } + + /// <summary> /// Adds items to a playlist. /// </summary> /// <param name="playlistId">The playlist id.</param> /// <param name="ids">Item id, comma delimited.</param> /// <param name="userId">The userId.</param> /// <response code="204">Items added to playlist.</response> + /// <response code="403">Access forbidden.</response> + /// <response code="404">Playlist not found.</response> /// <returns>An <see cref="NoContentResult"/> on success.</returns> [HttpPost("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> AddToPlaylist( + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> AddItemToPlaylist( [FromRoute, Required] Guid playlistId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); - await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false); + var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OwnerUserId.Equals(userId.Value) + || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(userId.Value)); + + if (!isPermitted) + { + return Forbid(); + } + + await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false); return NoContent(); } @@ -125,14 +367,34 @@ public class PlaylistsController : BaseJellyfinApiController /// <param name="itemId">The item id.</param> /// <param name="newIndex">The new index.</param> /// <response code="204">Item moved to new index.</response> + /// <response code="403">Access forbidden.</response> + /// <response code="404">Playlist not found.</response> /// <returns>An <see cref="NoContentResult"/> on success.</returns> [HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> MoveItem( [FromRoute, Required] string playlistId, [FromRoute, Required] string itemId, [FromRoute, Required] int newIndex) { + var callingUserId = User.GetUserId(); + + var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(playlistId), callingUserId); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OwnerUserId.Equals(callingUserId) + || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)); + + if (!isPermitted) + { + return Forbid(); + } + await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false); return NoContent(); } @@ -143,14 +405,34 @@ public class PlaylistsController : BaseJellyfinApiController /// <param name="playlistId">The playlist id.</param> /// <param name="entryIds">The item ids, comma delimited.</param> /// <response code="204">Items removed.</response> + /// <response code="403">Access forbidden.</response> + /// <response code="404">Playlist not found.</response> /// <returns>An <see cref="NoContentResult"/> on success.</returns> [HttpDelete("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult> RemoveFromPlaylist( + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> RemoveItemFromPlaylist( [FromRoute, Required] string playlistId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds) { - await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); + var callingUserId = User.GetUserId(); + + var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(playlistId), callingUserId); + if (playlist is null) + { + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OwnerUserId.Equals(callingUserId) + || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId)); + + if (!isPermitted) + { + return Forbid(); + } + + await _playlistManager.RemoveItemFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false); return NoContent(); } @@ -167,10 +449,12 @@ public class PlaylistsController : BaseJellyfinApiController /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param> /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Original playlist returned.</response> + /// <response code="404">Access forbidden.</response> /// <response code="404">Playlist not found.</response> /// <returns>The original playlist items.</returns> [HttpGet("{playlistId}/Items")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems( [FromRoute, Required] Guid playlistId, @@ -184,17 +468,31 @@ public class PlaylistsController : BaseJellyfinApiController [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { userId = RequestHelpers.GetUserId(User, userId); - var playlist = (Playlist)_libraryManager.GetItemById(playlistId); + var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value); if (playlist is null) { - return NotFound(); + return NotFound("Playlist not found"); + } + + var isPermitted = playlist.OpenAccess + || playlist.OwnerUserId.Equals(userId.Value) + || playlist.Shares.Any(s => s.UserId.Equals(userId.Value)); + + if (!isPermitted) + { + return Forbid(); } var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<Playlist>(playlistId, user); + if (item is null) + { + return NotFound(); + } - var items = playlist.GetManageableItems().ToArray(); + var items = item.GetManageableItems().ToArray(); var count = items.Length; if (startIndex.HasValue) { diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 949d101dc..88aa0178f 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -6,6 +6,7 @@ using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -76,21 +77,21 @@ public class PlaystateController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } - var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext, userId).ConfigureAwait(false); + var dto = UpdatePlayedStatus(user, item, true, datePlayed); foreach (var additionalUserInfo in session.AdditionalUsers) { @@ -141,21 +142,21 @@ public class PlaystateController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } - var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); - var item = _libraryManager.GetItemById(itemId); - + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } + var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext, userId).ConfigureAwait(false); + var dto = UpdatePlayedStatus(user, item, false, null); foreach (var additionalUserInfo in session.AdditionalUsers) { diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index f63e63927..6abd7a23e 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -22,7 +22,7 @@ namespace Jellyfin.Api.Controllers; /// <summary> /// Plugins controller. /// </summary> -[Authorize] +[Authorize(Policy = Policies.RequiresElevation)] public class PluginsController : BaseJellyfinApiController { private readonly IInstallationManager _installationManager; @@ -66,7 +66,6 @@ public class PluginsController : BaseJellyfinApiController /// <response code="404">Plugin not found.</response> /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> [HttpPost("{pluginId}/{version}/Enable")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) @@ -90,7 +89,6 @@ public class PluginsController : BaseJellyfinApiController /// <response code="404">Plugin not found.</response> /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> [HttpPost("{pluginId}/{version}/Disable")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) @@ -114,7 +112,6 @@ public class PluginsController : BaseJellyfinApiController /// <response code="404">Plugin not found.</response> /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> [HttpDelete("{pluginId}/{version}")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version) @@ -137,7 +134,6 @@ public class PluginsController : BaseJellyfinApiController /// <response code="404">Plugin not found.</response> /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns> [HttpDelete("{pluginId}")] - [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [Obsolete("Please use the UninstallPluginByVersion API.")] diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs index 595cab2df..a476005cb 100644 --- a/Jellyfin.Api/Controllers/RemoteImageController.cs +++ b/Jellyfin.Api/Controllers/RemoteImageController.cs @@ -6,8 +6,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using MediaBrowser.Common.Api; using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -68,7 +71,7 @@ public class RemoteImageController : BaseJellyfinApiController [FromQuery] string? providerName, [FromQuery] bool includeAllLanguages = false) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -127,7 +130,7 @@ public class RemoteImageController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -154,7 +157,7 @@ public class RemoteImageController : BaseJellyfinApiController [FromQuery, Required] ImageType type, [FromQuery] string? imageUrl) { - var item = _libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index 413b7b834..8bae6fb9b 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -211,7 +211,7 @@ public class SearchController : BaseJellyfinApiController if (!item.ChannelId.IsEmpty()) { - var channel = _libraryManager.GetItemById(item.ChannelId); + var channel = _libraryManager.GetItemById<BaseItem>(item.ChannelId); result.ChannelName = channel?.Name; } diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 52b58b8f1..60de66ab0 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -84,7 +84,8 @@ public class SessionController : BaseJellyfinApiController if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers)) { - result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(controllableByUserId.Value)); + // User cannot control other user's sessions, validate user id. + result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(RequestHelpers.GetUserId(User, controllableByUserId))); } if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl)) @@ -105,6 +106,11 @@ public class SessionController : BaseJellyfinApiController return true; }); } + else if (!User.IsInRole(UserRoles.Administrator)) + { + // Request isn't from administrator, limit to "own" sessions. + result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(User.GetUserId())); + } if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0) { diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index cc2a630e1..9da1dce93 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -12,6 +12,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using Jellyfin.Api.Models.SubtitleDtos; using MediaBrowser.Common.Api; using MediaBrowser.Common.Configuration; @@ -95,8 +96,7 @@ public class SubtitleController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] int index) { - var item = _libraryManager.GetItemById(itemId); - + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); @@ -113,18 +113,24 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="language">The language of the subtitles.</param> /// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param> /// <response code="200">Subtitles retrieved.</response> + /// <response code="404">Item not found.</response> /// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns> [HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")] [Authorize(Policy = Policies.SubtitleManagement)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles( [FromRoute, Required] Guid itemId, [FromRoute, Required] string language, [FromQuery] bool? isPerfectMatch) { - var video = (Video)_libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } - return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); + return await _subtitleManager.SearchSubtitles(item, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false); } /// <summary> @@ -133,22 +139,28 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="itemId">The item id.</param> /// <param name="subtitleId">The subtitle id.</param> /// <response code="204">Subtitle downloaded.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")] [Authorize(Policy = Policies.SubtitleManagement)] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> DownloadRemoteSubtitles( [FromRoute, Required] Guid itemId, [FromRoute, Required] string subtitleId) { - var video = (Video)_libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } try { - await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None) + await _subtitleManager.DownloadSubtitles(item, subtitleId, CancellationToken.None) .ConfigureAwait(false); - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); } catch (Exception ex) { @@ -165,7 +177,7 @@ public class SubtitleController : BaseJellyfinApiController /// <response code="200">File returned.</response> /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns> [HttpGet("Providers/Subtitles/Subtitles/{subtitleId}")] - [Authorize] + [Authorize(Policy = Policies.SubtitleManagement)] [ProducesResponseType(StatusCodes.Status200OK)] [Produces(MediaTypeNames.Application.Octet)] [ProducesFile("text/*")] @@ -223,7 +235,7 @@ public class SubtitleController : BaseJellyfinApiController if (string.IsNullOrEmpty(format)) { - var item = (Video)_libraryManager.GetItemById(itemId.Value); + var item = _libraryManager.GetItemById<Video>(itemId.Value); var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture); var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false) @@ -321,10 +333,12 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="mediaSourceId">The media source id.</param> /// <param name="segmentLength">The subtitle segment length.</param> /// <response code="200">Subtitle playlist retrieved.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesPlaylistFile] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")] public async Task<ActionResult> GetSubtitlePlaylist( @@ -333,7 +347,11 @@ public class SubtitleController : BaseJellyfinApiController [FromRoute, Required] string mediaSourceId, [FromQuery, Required] int segmentLength) { - var item = (Video)_libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false); @@ -397,15 +415,21 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="itemId">The item the subtitle belongs to.</param> /// <param name="body">The request body.</param> /// <response code="204">Subtitle uploaded.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="NoContentResult"/>.</returns> [HttpPost("Videos/{itemId}/Subtitles")] [Authorize(Policy = Policies.SubtitleManagement)] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> UploadSubtitle( [FromRoute, Required] Guid itemId, [FromBody, Required] UploadSubtitleDto body) { - var video = (Video)_libraryManager.GetItemById(itemId); + var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); + if (item is null) + { + return NotFound(); + } var bytes = Encoding.UTF8.GetBytes(body.Data); var memoryStream = new MemoryStream(bytes, 0, bytes.Length, false, true); @@ -416,7 +440,7 @@ public class SubtitleController : BaseJellyfinApiController await using (stream.ConfigureAwait(false)) { await _subtitleManager.UploadSubtitle( - video, + item, new SubtitleResponse { Format = body.Format, @@ -425,7 +449,7 @@ public class SubtitleController : BaseJellyfinApiController IsHearingImpaired = body.IsHearingImpaired, Stream = stream }).ConfigureAwait(false); - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); return NoContent(); } @@ -452,7 +476,7 @@ public class SubtitleController : BaseJellyfinApiController long? endPositionTicks, bool copyTimestamps) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById<BaseItem>(id); return _subtitleEncoder.GetSubtitles( item, diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index 4fbaafa2a..d7d0cc454 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -215,6 +215,7 @@ public class TrailersController : BaseJellyfinApiController hasSpecialFeature, hasTrailer, adjacentTo, + null, parentIndexNumber, hasParentalRating, isHd, diff --git a/Jellyfin.Api/Controllers/TrickplayController.cs b/Jellyfin.Api/Controllers/TrickplayController.cs index 2dc960229..0afe053da 100644 --- a/Jellyfin.Api/Controllers/TrickplayController.cs +++ b/Jellyfin.Api/Controllers/TrickplayController.cs @@ -5,6 +5,8 @@ using System.Text; using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model; @@ -84,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController [FromRoute, Required] int index, [FromQuery] Guid? mediaSourceId) { - var item = _libraryManager.GetItemById(mediaSourceId ?? itemId); + var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 3d84b61bf..426402667 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -231,20 +231,22 @@ public class TvShowsController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var shouldIncludeMissingEpisodes = (user is not null && user.DisplayMissingEpisodes) || User.GetIsApiKey(); if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. { - var item = _libraryManager.GetItemById(seasonId.Value); + var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value); if (item is not Season seasonItem) { return NotFound("No season exists with Id " + seasonId); } - episodes = seasonItem.GetEpisodes(user, dtoOptions); + episodes = seasonItem.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes); } else if (season.HasValue) // Season number was supplied. Get episodes by season number { - if (_libraryManager.GetItemById(seriesId) is not Series series) + var series = _libraryManager.GetItemById<Series>(seriesId); + if (series is null) { return NotFound("Series not found"); } @@ -255,16 +257,16 @@ public class TvShowsController : BaseJellyfinApiController episodes = seasonItem is null ? new List<BaseItem>() - : ((Season)seasonItem).GetEpisodes(user, dtoOptions); + : ((Season)seasonItem).GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes); } else // No season number or season id was supplied. Returning all episodes. { - if (_libraryManager.GetItemById(seriesId) is not Series series) + if (_libraryManager.GetItemById<BaseItem>(seriesId) is not Series series) { return NotFound("Series not found"); } - episodes = series.GetEpisodes(user, dtoOptions).ToList(); + episodes = series.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes).ToList(); } // Filter after the fact in case the ui doesn't want them @@ -342,13 +344,13 @@ public class TvShowsController : BaseJellyfinApiController var user = userId.IsNullOrEmpty() ? null : _userManager.GetUserById(userId.Value); - - if (_libraryManager.GetItemById(seriesId) is not Series series) + var item = _libraryManager.GetItemById<Series>(seriesId, user); + if (item is null) { - return NotFound("Series not found"); + return NotFound(); } - var seasons = series.GetItemList(new InternalItemsQuery(user) + var seasons = item.GetItemList(new InternalItemsQuery(user) { IsMissing = isMissing, IsSpecialSeason = isSpecialSeason, diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index db78e9946..fe7353496 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -9,12 +9,15 @@ using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.StreamingDtos; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Streaming; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -33,6 +36,7 @@ public class UniversalAudioController : BaseJellyfinApiController private readonly MediaInfoHelper _mediaInfoHelper; private readonly AudioHelper _audioHelper; private readonly DynamicHlsHelper _dynamicHlsHelper; + private readonly IUserManager _userManager; /// <summary> /// Initializes a new instance of the <see cref="UniversalAudioController"/> class. @@ -42,18 +46,21 @@ public class UniversalAudioController : BaseJellyfinApiController /// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param> /// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param> /// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> public UniversalAudioController( ILibraryManager libraryManager, ILogger<UniversalAudioController> logger, MediaInfoHelper mediaInfoHelper, AudioHelper audioHelper, - DynamicHlsHelper dynamicHlsHelper) + DynamicHlsHelper dynamicHlsHelper, + IUserManager userManager) { _libraryManager = libraryManager; _logger = logger; _mediaInfoHelper = mediaInfoHelper; _audioHelper = audioHelper; _dynamicHlsHelper = dynamicHlsHelper; + _userManager = userManager; } /// <summary> @@ -75,16 +82,19 @@ public class UniversalAudioController : BaseJellyfinApiController /// <param name="maxAudioSampleRate">Optional. The maximum audio sample rate.</param> /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param> /// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param> /// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param> /// <response code="200">Audio stream returned.</response> /// <response code="302">Redirected to remote audio stream.</response> + /// <response code="404">Item not found.</response> /// <returns>A <see cref="Task"/> containing the audio file.</returns> [HttpGet("Audio/{itemId}/universal")] [HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesAudioFile] public async Task<ActionResult> GetUniversalAudioStream( [FromRoute, Required] Guid itemId, @@ -103,25 +113,35 @@ public class UniversalAudioController : BaseJellyfinApiController [FromQuery] int? maxAudioSampleRate, [FromQuery] int? maxAudioBitDepth, [FromQuery] bool? enableRemoteMedia, + [FromQuery] bool enableAudioVbrEncoding = true, [FromQuery] bool breakOnNonKeyFrames = false, [FromQuery] bool enableRedirection = true) { - var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); userId = RequestHelpers.GetUserId(User, userId); + var user = userId.IsNullOrEmpty() + ? null + : _userManager.GetUserById(userId.Value); + var item = _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } + + var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile); var info = await _mediaInfoHelper.GetPlaybackInfo( - itemId, - userId, + item, + user, mediaSourceId) .ConfigureAwait(false); // set device specific data - var item = _libraryManager.GetItemById(itemId); - foreach (var sourceInfo in info.MediaSources) { + sourceInfo.TranscodingContainer = transcodingContainer; + sourceInfo.TranscodingSubProtocol = transcodingProtocol ?? sourceInfo.TranscodingSubProtocol; _mediaInfoHelper.SetDeviceSpecificData( item, sourceInfo, @@ -156,6 +176,8 @@ public class UniversalAudioController : BaseJellyfinApiController return Redirect(mediaSource.Path); } + // This one is currently very misleading as the SupportsDirectStream actually means "can direct play" + // The definition of DirectStream also seems changed during development var isStatic = mediaSource.SupportsDirectStream; if (!isStatic && mediaSource.TranscodingSubProtocol == MediaStreamProtocol.hls) { @@ -163,20 +185,25 @@ public class UniversalAudioController : BaseJellyfinApiController // ffmpeg option -> file extension // mpegts -> ts // fmp4 -> mp4 - // TODO: remove this when we switch back to the segment muxer var supportedHlsContainers = new[] { "ts", "mp4" }; + // fallback to mpegts if device reports some weird value unsupported by hls + var requestedSegmentContainer = Array.Exists( + supportedHlsContainers, + element => string.Equals(element, transcodingContainer, StringComparison.OrdinalIgnoreCase)) ? transcodingContainer : "ts"; + var segmentContainer = Array.Exists( + supportedHlsContainers, + element => string.Equals(element, mediaSource.TranscodingContainer, StringComparison.OrdinalIgnoreCase)) ? mediaSource.TranscodingContainer : requestedSegmentContainer; var dynamicHlsRequestDto = new HlsAudioRequestDto { Id = itemId, Container = ".m3u8", Static = isStatic, PlaySessionId = info.PlaySessionId, - // fallback to mpegts if device reports some weird value unsupported by hls - SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts", + SegmentContainer = segmentContainer, MediaSourceId = mediaSourceId, DeviceId = deviceId, - AudioCodec = audioCodec, + AudioCodec = mediaSource.TranscodeReasons == TranscodeReason.ContainerNotSupported ? "copy" : audioCodec, EnableAutoStreamCopy = true, AllowAudioStreamCopy = true, AllowVideoStreamCopy = true, @@ -194,7 +221,8 @@ public class UniversalAudioController : BaseJellyfinApiController TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(), Context = EncodingContext.Static, StreamOptions = new Dictionary<string, string>(), - EnableAdaptiveBitrateStreaming = true + EnableAdaptiveBitrateStreaming = true, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true) diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index c19ad33c8..421f1bfb5 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -77,8 +77,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -86,20 +86,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); var dtoOptions = new DtoOptions().AddClientFields(User); @@ -133,8 +125,8 @@ public class UserLibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<BaseItemDto> GetRootFolder([FromQuery] Guid? userId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -172,8 +164,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -181,20 +173,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false); var dtoOptions = new DtoOptions().AddClientFields(User); var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray(); @@ -231,8 +215,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -240,20 +224,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - return MarkFavorite(user, item, true); } @@ -286,8 +262,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -295,20 +271,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - return MarkFavorite(user, item, false); } @@ -341,8 +309,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -350,20 +318,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - return UpdateUserItemRatingInternal(user, item, null); } @@ -398,8 +358,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -407,20 +367,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - return UpdateUserItemRatingInternal(user, item, likes); } @@ -455,8 +407,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -464,20 +416,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - var dtoOptions = new DtoOptions().AddClientFields(User); if (item is IHasTrailers hasTrailers) { @@ -519,8 +463,8 @@ public class UserLibraryController : BaseJellyfinApiController [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { - var requestUserId = RequestHelpers.GetUserId(User, userId); - var user = _userManager.GetUserById(requestUserId); + userId = RequestHelpers.GetUserId(User, userId); + var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); @@ -528,20 +472,12 @@ public class UserLibraryController : BaseJellyfinApiController var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - + : _libraryManager.GetItemById<BaseItem>(itemId, user); if (item is null) { return NotFound(); } - if (item is not UserRootFolder - // Check the item is visible for the user - && !item.IsVisible(user)) - { - return Unauthorized($"{user.Username} is not permitted to access item {item.Name}."); - } - var dtoOptions = new DtoOptions().AddClientFields(User); return Ok(item diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs index bf3ce1d39..01da50d02 100644 --- a/Jellyfin.Api/Controllers/UserViewsController.cs +++ b/Jellyfin.Api/Controllers/UserViewsController.cs @@ -85,16 +85,11 @@ public class UserViewsController : BaseJellyfinApiController var folders = _userViewManager.GetUserViews(query); var dtoOptions = new DtoOptions().AddClientFields(User); - var fields = dtoOptions.Fields.ToList(); - - fields.Add(ItemFields.PrimaryImageAspectRatio); - fields.Add(ItemFields.DisplayPreferencesId); - dtoOptions.Fields = fields.ToArray(); + dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId]; var user = _userManager.GetUserById(userId.Value); - var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)) - .ToArray(); + var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user)); return new QueryResult<BaseItemDto>(dtos); } diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs index 23b9ba46f..b67c6fdb7 100644 --- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -4,7 +4,10 @@ using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; +using Jellyfin.Api.Extensions; +using Jellyfin.Api.Helpers; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using Microsoft.AspNetCore.Http; @@ -54,7 +57,7 @@ public class VideoAttachmentsController : BaseJellyfinApiController { try { - var item = _libraryManager.GetItemById(videoId); + var item = _libraryManager.GetItemById<BaseItem>(videoId, User.GetUserId()); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 380120032..7f9608378 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -7,7 +7,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; @@ -105,7 +104,11 @@ public class VideosController : BaseJellyfinApiController ? (userId.IsNullOrEmpty() ? _libraryManager.RootFolder : _libraryManager.GetUserRootFolder()) - : _libraryManager.GetItemById(itemId); + : _libraryManager.GetItemById<BaseItem>(itemId, user); + if (item is null) + { + return NotFound(); + } var dtoOptions = new DtoOptions(); dtoOptions = dtoOptions.AddClientFields(User); @@ -139,24 +142,23 @@ public class VideosController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId) { - var video = (Video)_libraryManager.GetItemById(itemId); - - if (video is null) + var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); + if (item is null) { - return NotFound("The video either does not exist or the id does not belong to a video."); + return NotFound(); } - if (video.LinkedAlternateVersions.Length == 0) + if (item.LinkedAlternateVersions.Length == 0) { - video = (Video?)_libraryManager.GetItemById(video.PrimaryVersionId); + item = _libraryManager.GetItemById<Video>(Guid.Parse(item.PrimaryVersionId)); } - if (video is null) + if (item is null) { return NotFound(); } - foreach (var link in video.GetLinkedAlternateVersions()) + foreach (var link in item.GetLinkedAlternateVersions()) { link.SetPrimaryVersionId(null); link.LinkedAlternateVersions = Array.Empty<LinkedChild>(); @@ -164,9 +166,9 @@ public class VideosController : BaseJellyfinApiController await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } - video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - video.SetPrimaryVersionId(null); - await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + item.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + item.SetPrimaryVersionId(null); + await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); return NoContent(); } @@ -184,8 +186,9 @@ public class VideosController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids) { + var userId = User.GetUserId(); var items = ids - .Select(i => _libraryManager.GetItemById(i)) + .Select(i => _libraryManager.GetItemById<BaseItem>(i, userId)) .OfType<Video>() .OrderBy(i => i.Id) .ToList(); @@ -303,6 +306,7 @@ public class VideosController : BaseJellyfinApiController /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <response code="200">Video stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("{itemId}/stream")] @@ -360,7 +364,8 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAudioVbrEncoding = true) { var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; // CTS lifecycle is managed internally. @@ -416,7 +421,8 @@ public class VideosController : BaseJellyfinApiController AudioStreamIndex = audioStreamIndex, VideoStreamIndex = videoStreamIndex, Context = context ?? EncodingContext.Streaming, - StreamOptions = streamOptions + StreamOptions = streamOptions, + EnableAudioVbrEncoding = enableAudioVbrEncoding }; var state = await StreamingHelpers.GetStreamingState( @@ -541,6 +547,7 @@ public class VideosController : BaseJellyfinApiController /// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param> /// <param name="context">Optional. The <see cref="EncodingContext"/>.</param> /// <param name="streamOptions">Optional. The streaming options.</param> + /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param> /// <response code="200">Video stream returned.</response> /// <returns>A <see cref="FileResult"/> containing the audio file.</returns> [HttpGet("{itemId}/stream.{container}")] @@ -598,7 +605,8 @@ public class VideosController : BaseJellyfinApiController [FromQuery] int? audioStreamIndex, [FromQuery] int? videoStreamIndex, [FromQuery] EncodingContext? context, - [FromQuery] Dictionary<string, string> streamOptions) + [FromQuery] Dictionary<string, string> streamOptions, + [FromQuery] bool enableAudioVbrEncoding = true) { return GetVideoStream( itemId, @@ -651,6 +659,7 @@ public class VideosController : BaseJellyfinApiController audioStreamIndex, videoStreamIndex, context, - streamOptions); + streamOptions, + enableAudioVbrEncoding); } } diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs index 7d9823c25..3d17dbda1 100644 --- a/Jellyfin.Api/Extensions/DtoExtensions.cs +++ b/Jellyfin.Api/Extensions/DtoExtensions.cs @@ -43,11 +43,7 @@ public static class DtoExtensions client.Contains("media center", StringComparison.OrdinalIgnoreCase) || client.Contains("classic", StringComparison.OrdinalIgnoreCase)) { - int oldLen = dtoOptions.Fields.Count; - var arr = new ItemFields[oldLen + 1]; - dtoOptions.Fields.CopyTo(arr, 0); - arr[oldLen] = ItemFields.RecursiveItemCount; - dtoOptions.Fields = arr; + dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.RecursiveItemCount]; } } @@ -61,11 +57,7 @@ public static class DtoExtensions client.Contains("samsung", StringComparison.OrdinalIgnoreCase) || client.Contains("androidtv", StringComparison.OrdinalIgnoreCase)) { - int oldLen = dtoOptions.Fields.Count; - var arr = new ItemFields[oldLen + 1]; - dtoOptions.Fields.CopyTo(arr, 0); - arr[oldLen] = ItemFields.ChildCount; - dtoOptions.Fields = arr; + dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.ChildCount]; } } diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index e5e4356f8..ba92d811c 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -151,6 +151,14 @@ public class DynamicHlsHelper var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString(); + // from universal audio service, need to override the AudioCodec when the actual request differs from original query + if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase)) + { + var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString()); + newQuery["AudioCodec"] = state.OutputAudioCodec; + queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery); + } + // from universal audio service if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer) && !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase)) @@ -725,6 +733,21 @@ public class DynamicHlsHelper return HlsCodecStringHelpers.GetAv1String(profile, level, false, bitDepth); } + // VP9 HLS is for video remuxing only, everything is probed from the original video + if (string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase)) + { + var width = state.VideoStream.Width ?? 0; + var height = state.VideoStream.Height ?? 0; + var framerate = state.VideoStream.AverageFrameRate ?? 30; + var bitDepth = state.VideoStream.BitDepth ?? 8; + return HlsCodecStringHelpers.GetVp9String( + width, + height, + state.VideoStream.PixelFormat, + framerate, + bitDepth); + } + return string.Empty; } diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index 5eec1b0ca..d0bfa1fbe 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -183,6 +183,68 @@ public static class HlsCodecStringHelpers } /// <summary> + /// Gets a VP9 codec string. + /// </summary> + /// <param name="width">Video width.</param> + /// <param name="height">Video height.</param> + /// <param name="pixelFormat">Video pixel format.</param> + /// <param name="framerate">Video framerate.</param> + /// <param name="bitDepth">Video bitDepth.</param> + /// <returns>The VP9 codec string.</returns> + public static string GetVp9String(int width, int height, string pixelFormat, float framerate, int bitDepth) + { + // refer: https://www.webmproject.org/vp9/mp4/ + StringBuilder result = new StringBuilder("vp09", 13); + + var profileString = pixelFormat switch + { + "yuv420p" => "00", + "yuvj420p" => "00", + "yuv422p" => "01", + "yuv444p" => "01", + "yuv420p10le" => "02", + "yuv420p12le" => "02", + "yuv422p10le" => "03", + "yuv422p12le" => "03", + "yuv444p10le" => "03", + "yuv444p12le" => "03", + _ => "00" + }; + + var lumaPictureSize = width * height; + var lumaSampleRate = lumaPictureSize * framerate; + var levelString = lumaPictureSize switch + { + <= 0 => "00", + <= 36864 => "10", + <= 73728 => "11", + <= 122880 => "20", + <= 245760 => "21", + <= 552960 => "30", + <= 983040 => "31", + <= 2228224 => lumaSampleRate <= 83558400 ? "40" : "41", + <= 8912896 => lumaSampleRate <= 311951360 ? "50" : (lumaSampleRate <= 588251136 ? "51" : "52"), + <= 35651584 => lumaSampleRate <= 1176502272 ? "60" : (lumaSampleRate <= 4706009088 ? "61" : "62"), + _ => "00" // This should not happen + }; + + if (bitDepth != 8 + && bitDepth != 10 + && bitDepth != 12) + { + // Default to 8 bits + bitDepth = 8; + } + + result.Append('.').Append(profileString).Append('.').Append(levelString); + var bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture); + result.Append('.') + .Append(bitDepthD2); + + return result.ToString(); + } + + /// <summary> /// Gets an AV1 codec string. /// </summary> /// <param name="profile">AV1 profile.</param> @@ -192,7 +254,7 @@ public static class HlsCodecStringHelpers /// <returns>The AV1 codec string.</returns> public static string GetAv1String(string? profile, int level, bool tierFlag, int bitDepth) { - // https://aomedia.org/av1/specification/annex-a/ + // https://aomediacodec.github.io/av1-isobmff/#codecsparam // FORMAT: [codecTag].[profile].[level][tier].[bitDepth] StringBuilder result = new StringBuilder("av01", 13); @@ -214,8 +276,7 @@ public static class HlsCodecStringHelpers result.Append(".0"); } - if (level <= 0 - || level > 31) + if (level is <= 0 or > 31) { // Default to the maximum defined level 6.3 level = 19; @@ -230,7 +291,8 @@ public static class HlsCodecStringHelpers } result.Append('.') - .Append(level) + // Needed to pad it double digits; otherwise, browsers will reject the stream. + .AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", level) .Append(tierFlag ? 'H' : 'M'); string bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture); diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 6a24ad32a..212d678a8 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -24,6 +24,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Helpers; @@ -76,21 +77,17 @@ public class MediaInfoHelper /// <summary> /// Get playback info. /// </summary> - /// <param name="id">Item id.</param> - /// <param name="userId">User Id.</param> + /// <param name="item">The item.</param> + /// <param name="user">The user.</param> /// <param name="mediaSourceId">Media source id.</param> /// <param name="liveStreamId">Live stream id.</param> /// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns> public async Task<PlaybackInfoResponse> GetPlaybackInfo( - Guid id, - Guid? userId, + BaseItem item, + User? user, string? mediaSourceId = null, string? liveStreamId = null) { - var user = userId.IsNullOrEmpty() - ? null - : _userManager.GetUserById(userId.Value); - var item = _libraryManager.GetItemById(id); var result = new PlaybackInfoResponse(); MediaSourceInfo[] mediaSources; @@ -402,7 +399,8 @@ public class MediaInfoHelper if (profile is not null) { - var item = _libraryManager.GetItemById(request.ItemId); + var item = _libraryManager.GetItemById<BaseItem>(request.ItemId) + ?? throw new ResourceNotFoundException(); SetDeviceSpecificData( item, diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 429e97213..a3d7f471e 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -117,10 +117,15 @@ public static class RequestHelpers return user.EnableUserPreferenceAccess; } - internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext) + internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null) { - var userId = httpContext.User.GetUserId(); - var user = userManager.GetUserById(userId); + userId ??= httpContext.User.GetUserId(); + User? user = null; + if (!userId.IsNullOrEmpty()) + { + user = userManager.GetUserById(userId.Value); + } + var session = await sessionManager.LogSessionActivity( httpContext.User.GetClient(), httpContext.User.GetVersion(), diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index bfe71fd87..535ef27c3 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -11,6 +11,7 @@ using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Streaming; @@ -18,6 +19,7 @@ using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Net.Http.Headers; namespace Jellyfin.Api.Helpers; @@ -107,7 +109,8 @@ public static class StreamingHelpers ?? state.SupportedSubtitleCodecs.FirstOrDefault(); } - var item = libraryManager.GetItemById(streamingRequest.Id); + var item = libraryManager.GetItemById<BaseItem>(streamingRequest.Id) + ?? throw new ResourceNotFoundException(); state.IsInputVideo = item.MediaType == MediaType.Video; @@ -125,7 +128,7 @@ public static class StreamingHelpers if (mediaSource is null) { - var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false); + var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById<BaseItem>(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false); mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId) ? mediaSources[0] @@ -139,6 +142,25 @@ public static class StreamingHelpers } else { + // Enforce more restrictive transcoding profile for LiveTV due to compatability reasons + // Cap the MaxStreamingBitrate to 30Mbps, because we are unable to reliably probe source bitrate, + // which will cause the client to request extremely high bitrate that may fail the player/encoder + streamingRequest.VideoBitRate = streamingRequest.VideoBitRate > 30000000 ? 30000000 : streamingRequest.VideoBitRate; + + if (streamingRequest.SegmentContainer is not null) + { + // Remove all fmp4 transcoding profiles, because it causes playback error and/or A/V sync issues + // Notably: Some channels won't play on FireFox and LG webOS + // Some channels from HDHomerun will experience A/V sync issues + streamingRequest.SegmentContainer = "ts"; + streamingRequest.VideoCodec = "h264"; + streamingRequest.AudioCodec = "aac"; + state.SupportedVideoCodecs = ["h264"]; + state.Request.VideoCodec = "h264"; + state.SupportedAudioCodecs = ["aac"]; + state.Request.AudioCodec = "aac"; + } + var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false); mediaSource = liveStreamInfo.Item1; state.DirectStreamProvider = liveStreamInfo.Item2; @@ -163,6 +185,9 @@ public static class StreamingHelpers } var outputAudioCodec = streamingRequest.AudioCodec; + state.OutputAudioCodec = outputAudioCodec; + state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); + state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); if (EncodingHelper.LosslessAudioCodecs.Contains(outputAudioCodec)) { state.OutputAudioBitrate = state.AudioStream.BitRate ?? 0; @@ -177,10 +202,6 @@ public static class StreamingHelpers containerInternal = ".pcm"; } - state.OutputAudioCodec = outputAudioCodec; - state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); - state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); - if (state.VideoRequest is not null) { state.OutputVideoCodec = state.Request.VideoCodec; diff --git a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs index 94ffc5238..7a549aada 100644 --- a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs +++ b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs @@ -12,7 +12,7 @@ public class MediaPathDto /// Gets or sets the name of the library. /// </summary> [Required] - public string? Name { get; set; } + public required string Name { get; set; } /// <summary> /// Gets or sets the path to add. diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs index bdc488871..3cbdd031a 100644 --- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs +++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Model.Entities; namespace Jellyfin.Api.Models.PlaylistDtos; @@ -14,13 +15,13 @@ public class CreatePlaylistDto /// <summary> /// Gets or sets the name of the new playlist. /// </summary> - public string? Name { get; set; } + public required string Name { get; set; } /// <summary> /// Gets or sets item ids to add to the playlist. /// </summary> [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] - public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>(); + public IReadOnlyList<Guid> Ids { get; set; } = []; /// <summary> /// Gets or sets the user id. @@ -31,4 +32,14 @@ public class CreatePlaylistDto /// Gets or sets the media type. /// </summary> public MediaType? MediaType { get; set; } + + /// <summary> + /// Gets or sets the playlist users. + /// </summary> + public IReadOnlyList<PlaylistUserPermissions> Users { get; set; } = []; + + /// <summary> + /// Gets or sets a value indicating whether the playlist is public. + /// </summary> + public bool IsPublic { get; set; } = true; } diff --git a/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs new file mode 100644 index 000000000..80e20995c --- /dev/null +++ b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Jellyfin.Extensions.Json.Converters; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.Api.Models.PlaylistDtos; + +/// <summary> +/// Update existing playlist dto. Fields set to `null` will not be updated and keep their current values. +/// </summary> +public class UpdatePlaylistDto +{ + /// <summary> + /// Gets or sets the name of the new playlist. + /// </summary> + public string? Name { get; set; } + + /// <summary> + /// Gets or sets item ids of the playlist. + /// </summary> + [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))] + public IReadOnlyList<Guid>? Ids { get; set; } + + /// <summary> + /// Gets or sets the playlist users. + /// </summary> + public IReadOnlyList<PlaylistUserPermissions>? Users { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the playlist is public. + /// </summary> + public bool? IsPublic { get; set; } +} diff --git a/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs new file mode 100644 index 000000000..60467b5e7 --- /dev/null +++ b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs @@ -0,0 +1,12 @@ +namespace Jellyfin.Api.Models.PlaylistDtos; + +/// <summary> +/// Update existing playlist user dto. Fields set to `null` will not be updated and keep their current values. +/// </summary> +public class UpdatePlaylistUserDto +{ + /// <summary> + /// Gets or sets a value indicating whether the user can edit the playlist. + /// </summary> + public bool? CanEdit { get; set; } +} |
