diff options
Diffstat (limited to 'Jellyfin.Api')
20 files changed, 491 insertions, 289 deletions
diff --git a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs index e72bec46f..764c0a435 100644 --- a/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs +++ b/Jellyfin.Api/Auth/UserPermissionPolicy/UserPermissionHandler.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Jellyfin.Api.Extensions; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; @@ -25,15 +26,27 @@ namespace Jellyfin.Api.Auth.UserPermissionPolicy /// <inheritdoc /> protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, UserPermissionRequirement requirement) { - var user = _userManager.GetUserById(context.User.GetUserId()); - if (user is null) + // Api keys have global permissions, so just succeed the requirement. + if (context.User.GetIsApiKey()) { - throw new ResourceNotFoundException(); + context.Succeed(requirement); } - - if (user.HasPermission(requirement.RequiredPermission)) + else { - context.Succeed(requirement); + var userId = context.User.GetUserId(); + if (!userId.IsEmpty()) + { + var user = _userManager.GetUserById(context.User.GetUserId()); + if (user is null) + { + throw new ResourceNotFoundException(); + } + + if (user.HasPermission(requirement.RequiredPermission)) + { + context.Succeed(requirement); + } + } } return Task.CompletedTask; diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 6f0006832..1cad66326 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; +using Jellyfin.Api.Helpers; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Common.Extensions; @@ -48,15 +49,17 @@ public class DisplayPreferencesController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] public ActionResult<DisplayPreferencesDto> GetDisplayPreferences( [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, + [FromQuery] Guid? userId, [FromQuery, Required] string client) { + userId = RequestHelpers.GetUserId(User, userId); + if (!Guid.TryParse(displayPreferencesId, out var itemId)) { itemId = displayPreferencesId.GetMD5(); } - var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId.Value, itemId, client); var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client); itemPreferences.ItemId = itemId; @@ -113,10 +116,12 @@ public class DisplayPreferencesController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")] public ActionResult UpdateDisplayPreferences( [FromRoute, Required] string displayPreferencesId, - [FromQuery, Required] Guid userId, + [FromQuery] Guid? userId, [FromQuery, Required] string client, [FromBody, Required] DisplayPreferencesDto displayPreferences) { + userId = RequestHelpers.GetUserId(User, userId); + HomeSectionType[] defaults = { HomeSectionType.SmallLibraryTiles, @@ -134,7 +139,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController itemId = displayPreferencesId.GetMD5(); } - var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client); + var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId.Value, itemId, client); existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : null; existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop; existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar; @@ -204,7 +209,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController itemPrefs.ItemId = itemId; // Set all remaining custom preferences. - _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); + _displayPreferencesManager.SetCustomItemDisplayPreferences(userId.Value, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs); _displayPreferencesManager.SaveChanges(); return NoContent(); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index dda1e9d56..590cdc33f 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -294,9 +294,7 @@ public class DynamicHlsController : BaseJellyfinApiController if (!System.IO.File.Exists(playlistPath)) { - var transcodingLock = _transcodeManager.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - try + using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false)) { if (!System.IO.File.Exists(playlistPath)) { @@ -326,10 +324,6 @@ public class DynamicHlsController : BaseJellyfinApiController } } } - finally - { - transcodingLock.Release(); - } } job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); @@ -1442,95 +1436,80 @@ public class DynamicHlsController : BaseJellyfinApiController return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - var transcodingLock = _transcodeManager.GetTranscodingLock(playlistPath); - await transcodingLock.WaitAsync(cancellationToken).ConfigureAwait(false); - var released = false; - var startTranscoding = false; - - try + using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false)) { + var startTranscoding = false; if (System.IO.File.Exists(segmentPath)) { job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - transcodingLock.Release(); - released = true; _logger.LogDebug("returning {0} [it exists, try 2]", segmentPath); return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false); } - else - { - var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); - var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - - if (segmentId == -1) - { - _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); - startTranscoding = true; - segmentId = 0; - } - else if (currentTranscodingIndex is null) - { - _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); - startTranscoding = true; - } - else if (segmentId < currentTranscodingIndex.Value) - { - _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); - startTranscoding = true; - } - else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) - { - _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); - startTranscoding = true; - } - if (startTranscoding) - { - // If the playlist doesn't already exist, startup ffmpeg - try - { - await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) - .ConfigureAwait(false); + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; - if (currentTranscodingIndex.HasValue) - { - DeleteLastFile(playlistPath, segmentExtension, 0); - } + if (segmentId == -1) + { + _logger.LogDebug("Starting transcoding because fmp4 init file is being requested"); + startTranscoding = true; + segmentId = 0; + } + else if (currentTranscodingIndex is null) + { + _logger.LogDebug("Starting transcoding because currentTranscodingIndex=null"); + startTranscoding = true; + } + else if (segmentId < currentTranscodingIndex.Value) + { + _logger.LogDebug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", segmentId, currentTranscodingIndex); + startTranscoding = true; + } + else if (segmentId - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) + { + _logger.LogDebug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", segmentId - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, segmentId); + startTranscoding = true; + } - streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; + if (startTranscoding) + { + // If the playlist doesn't already exist, startup ffmpeg + try + { + await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false) + .ConfigureAwait(false); - state.WaitForPath = segmentPath; - job = await _transcodeManager.StartFfMpeg( - state, - playlistPath, - GetCommandLineArguments(playlistPath, state, false, segmentId), - Request.HttpContext.User.GetUserId(), - TranscodingJobType, - cancellationTokenSource).ConfigureAwait(false); - } - catch + if (currentTranscodingIndex.HasValue) { - state.Dispose(); - throw; + DeleteLastFile(playlistPath, segmentExtension, 0); } - // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); + streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks; + + state.WaitForPath = segmentPath; + job = await _transcodeManager.StartFfMpeg( + state, + playlistPath, + GetCommandLineArguments(playlistPath, state, false, segmentId), + Request.HttpContext.User.GetUserId(), + TranscodingJobType, + cancellationTokenSource).ConfigureAwait(false); } - else + catch { - job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); - if (job?.TranscodingThrottler is not null) - { - await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); - } + state.Dispose(); + throw; } + + // await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); } - } - finally - { - if (!released) + else { - transcodingLock.Release(); + job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + if (job?.TranscodingThrottler is not null) + { + await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false); + } } } diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs index e7ff1f986..3cf485299 100644 --- a/Jellyfin.Api/Controllers/InstantMixController.cs +++ b/Jellyfin.Api/Controllers/InstantMixController.cs @@ -53,7 +53,7 @@ public class InstantMixController : BaseJellyfinApiController /// <summary> /// Creates an instant playlist based on a given song. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="itemId">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> @@ -63,10 +63,10 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Songs/{id}/InstantMix")] + [HttpGet("Songs/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -75,7 +75,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -90,7 +90,7 @@ public class InstantMixController : BaseJellyfinApiController /// <summary> /// Creates an instant playlist based on a given album. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="itemId">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> @@ -100,10 +100,10 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Albums/{id}/InstantMix")] + [HttpGet("Albums/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -112,7 +112,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var album = _libraryManager.GetItemById(id); + var album = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -127,7 +127,7 @@ public class InstantMixController : BaseJellyfinApiController /// <summary> /// Creates an instant playlist based on a given playlist. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="itemId">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> @@ -137,10 +137,10 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Playlists/{id}/InstantMix")] + [HttpGet("Playlists/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -149,7 +149,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var playlist = (Playlist)_libraryManager.GetItemById(id); + var playlist = (Playlist)_libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -200,7 +200,7 @@ public class InstantMixController : BaseJellyfinApiController /// <summary> /// Creates an instant playlist based on a given artist. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="itemId">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> @@ -210,10 +210,10 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Artists/{id}/InstantMix")] + [HttpGet("Artists/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -222,7 +222,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null @@ -237,7 +237,7 @@ public class InstantMixController : BaseJellyfinApiController /// <summary> /// Creates an instant playlist based on a given item. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="itemId">The item id.</param> /// <param name="userId">Optional. Filter by user id, and attach user data.</param> /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output.</param> @@ -247,10 +247,10 @@ public class InstantMixController : BaseJellyfinApiController /// <param name="enableImageTypes">Optional. The image types to include in the output.</param> /// <response code="200">Instant playlist returned.</response> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns> - [HttpGet("Items/{id}/InstantMix")] + [HttpGet("Items/{itemId}/InstantMix")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem( - [FromRoute, Required] Guid id, + [FromRoute, Required] Guid itemId, [FromQuery] Guid? userId, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -259,7 +259,7 @@ public class InstantMixController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { - var item = _libraryManager.GetItemById(id); + var item = _libraryManager.GetItemById(itemId); userId = RequestHelpers.GetUserId(User, userId); var user = userId.IsNullOrEmpty() ? null diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index a0bbc961f..984dc7789 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -7,7 +7,6 @@ using System.Linq; 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; @@ -17,7 +16,6 @@ using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Api; using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -313,7 +311,7 @@ public class LibraryController : BaseJellyfinApiController { try { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { @@ -915,6 +913,7 @@ public class LibraryController : BaseJellyfinApiController User.GetUserId()) { ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), User.GetClient(), User.GetDevice()), + ItemId = item.Id.ToString("N", CultureInfo.InvariantCulture) }).ConfigureAwait(false); } catch diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index d483ca4d2..23c430f85 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -6,11 +6,9 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Api.Constants; using Jellyfin.Api.ModelBinders; using Jellyfin.Api.Models.LibraryStructureDto; using MediaBrowser.Common.Api; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -180,7 +178,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } else { @@ -224,7 +222,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } else { @@ -293,7 +291,7 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); } else { diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 1b2f5750f..7768b3c45 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -43,7 +43,10 @@ namespace Jellyfin.Api.Controllers; public class LiveTvController : BaseJellyfinApiController { private readonly ILiveTvManager _liveTvManager; + private readonly IGuideManager _guideManager; private readonly ITunerHostManager _tunerHostManager; + private readonly IListingsManager _listingsManager; + private readonly IRecordingsManager _recordingsManager; private readonly IUserManager _userManager; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; @@ -56,7 +59,10 @@ public class LiveTvController : BaseJellyfinApiController /// Initializes a new instance of the <see cref="LiveTvController"/> class. /// </summary> /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param> + /// <param name="guideManager">Instance of the <see cref="IGuideManager"/> interface.</param> /// <param name="tunerHostManager">Instance of the <see cref="ITunerHostManager"/> interface.</param> + /// <param name="listingsManager">Instance of the <see cref="IListingsManager"/> interface.</param> + /// <param name="recordingsManager">Instance of the <see cref="IRecordingsManager"/> interface.</param> /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> @@ -66,7 +72,10 @@ public class LiveTvController : BaseJellyfinApiController /// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param> public LiveTvController( ILiveTvManager liveTvManager, + IGuideManager guideManager, ITunerHostManager tunerHostManager, + IListingsManager listingsManager, + IRecordingsManager recordingsManager, IUserManager userManager, IHttpClientFactory httpClientFactory, ILibraryManager libraryManager, @@ -76,7 +85,10 @@ public class LiveTvController : BaseJellyfinApiController ITranscodeManager transcodeManager) { _liveTvManager = liveTvManager; + _guideManager = guideManager; _tunerHostManager = tunerHostManager; + _listingsManager = listingsManager; + _recordingsManager = recordingsManager; _userManager = userManager; _httpClientFactory = httpClientFactory; _libraryManager = libraryManager; @@ -624,7 +636,7 @@ public class LiveTvController : BaseJellyfinApiController [Authorize(Policy = Policies.LiveTvAccess)] public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms([FromBody] GetProgramsDto body) { - var user = body.UserId.IsEmpty() ? null : _userManager.GetUserById(body.UserId); + var user = body.UserId.IsNullOrEmpty() ? null : _userManager.GetUserById(body.UserId.Value); var query = new InternalItemsQuery(user) { @@ -941,9 +953,7 @@ public class LiveTvController : BaseJellyfinApiController [Authorize(Policy = Policies.LiveTvAccess)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<GuideInfo> GetGuideInfo() - { - return _liveTvManager.GetGuideInfo(); - } + => _guideManager.GetGuideInfo(); /// <summary> /// Adds a tuner host. @@ -1013,7 +1023,7 @@ public class LiveTvController : BaseJellyfinApiController listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant(); } - return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); + return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false); } /// <summary> @@ -1027,7 +1037,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult DeleteListingProvider([FromQuery] string? id) { - _liveTvManager.DeleteListingsProvider(id); + _listingsManager.DeleteListingsProvider(id); return NoContent(); } @@ -1048,9 +1058,7 @@ public class LiveTvController : BaseJellyfinApiController [FromQuery] string? type, [FromQuery] string? location, [FromQuery] string? country) - { - return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false); - } + => await _listingsManager.GetLineups(type, id, country, location).ConfigureAwait(false); /// <summary> /// Gets available countries. @@ -1081,48 +1089,20 @@ public class LiveTvController : BaseJellyfinApiController [HttpGet("ChannelMappingOptions")] [Authorize(Policy = Policies.LiveTvAccess)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<ChannelMappingOptionsDto>> GetChannelMappingOptions([FromQuery] string? providerId) - { - var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv"); - - var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); - - var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name; - - var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None) - .ConfigureAwait(false); - - var mappings = listingsProviderInfo.ChannelMappings; - - return new ChannelMappingOptionsDto - { - TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(), - ProviderChannels = providerChannels.Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Id - }).ToList(), - Mappings = mappings, - ProviderName = listingsProviderName - }; - } + public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId) + => _listingsManager.GetChannelMappingOptions(providerId); /// <summary> /// Set channel mappings. /// </summary> - /// <param name="setChannelMappingDto">The set channel mapping dto.</param> + /// <param name="dto">The set channel mapping dto.</param> /// <response code="200">Created channel mapping returned.</response> /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns> [HttpPost("ChannelMappings")] [Authorize(Policy = Policies.LiveTvManagement)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping([FromBody, Required] SetChannelMappingDto setChannelMappingDto) - { - return await _liveTvManager.SetChannelMapping(setChannelMappingDto.ProviderId, setChannelMappingDto.TunerChannelId, setChannelMappingDto.ProviderChannelId).ConfigureAwait(false); - } + public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto) + => _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId); /// <summary> /// Get tuner host types. @@ -1164,8 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController [ProducesVideoFile] public ActionResult GetLiveRecordingFile([FromRoute, Required] string recordingId) { - var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId); - + var path = _recordingsManager.GetActiveRecordingPath(recordingId); if (string.IsNullOrWhiteSpace(path)) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/LyricsController.cs b/Jellyfin.Api/Controllers/LyricsController.cs new file mode 100644 index 000000000..4fccf2cb4 --- /dev/null +++ b/Jellyfin.Api/Controllers/LyricsController.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Attributes; +using Jellyfin.Api.Extensions; +using Jellyfin.Extensions; +using MediaBrowser.Common.Api; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Lyrics; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Lyrics; +using MediaBrowser.Model.Providers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers; + +/// <summary> +/// Lyrics controller. +/// </summary> +[Route("")] +public class LyricsController : BaseJellyfinApiController +{ + private readonly ILibraryManager _libraryManager; + private readonly ILyricManager _lyricManager; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + private readonly IUserManager _userManager; + + /// <summary> + /// Initializes a new instance of the <see cref="LyricsController"/> class. + /// </summary> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + public LyricsController( + ILibraryManager libraryManager, + ILyricManager lyricManager, + IProviderManager providerManager, + IFileSystem fileSystem, + IUserManager userManager) + { + _libraryManager = libraryManager; + _lyricManager = lyricManager; + _providerManager = providerManager; + _fileSystem = fileSystem; + _userManager = userManager; + } + + /// <summary> + /// Gets an item's lyrics. + /// </summary> + /// <param name="itemId">Item id.</param> + /// <response code="200">Lyrics returned.</response> + /// <response code="404">Something went wrong. No Lyrics will be returned.</response> + /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns> + [HttpGet("Audio/{itemId}/Lyrics")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + 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) + { + 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); + if (result is not null) + { + return Ok(result); + } + + return NotFound(); + } + + /// <summary> + /// Upload an external lyric file. + /// </summary> + /// <param name="itemId">The item the lyric belongs to.</param> + /// <param name="fileName">Name of the file being uploaded.</param> + /// <response code="200">Lyrics uploaded.</response> + /// <response code="400">Error processing upload.</response> + /// <response code="404">Item not found.</response> + /// <returns>The uploaded lyric.</returns> + [HttpPost("Audio/{itemId}/Lyrics")] + [Authorize(Policy = Policies.LyricManagement)] + [AcceptsFile(MediaTypeNames.Text.Plain)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<LyricDto>> UploadLyrics( + [FromRoute, Required] Guid itemId, + [FromQuery, Required] string fileName) + { + var audio = _libraryManager.GetItemById<Audio>(itemId); + if (audio is null) + { + return NotFound(); + } + + if (Request.ContentLength.GetValueOrDefault(0) == 0) + { + return BadRequest("No lyrics uploaded"); + } + + // Utilize Path.GetExtension as it provides extra path validation. + var format = Path.GetExtension(fileName.AsSpan()).RightPart('.').ToString(); + if (string.IsNullOrEmpty(format)) + { + return BadRequest("Extension is required on filename"); + } + + var stream = new MemoryStream(); + await using (stream.ConfigureAwait(false)) + { + await Request.Body.CopyToAsync(stream).ConfigureAwait(false); + var uploadedLyric = await _lyricManager.UploadLyricAsync( + audio, + new LyricResponse + { + Format = format, + Stream = stream + }).ConfigureAwait(false); + + if (uploadedLyric is null) + { + return BadRequest(); + } + + _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + return Ok(uploadedLyric); + } + } + + /// <summary> + /// Deletes an external lyric file. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="204">Lyric deleted.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpDelete("Audio/{itemId}/Lyrics")] + [Authorize(Policy = Policies.LyricManagement)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult> DeleteLyrics( + [FromRoute, Required] Guid itemId) + { + var audio = _libraryManager.GetItemById<Audio>(itemId); + if (audio is null) + { + return NotFound(); + } + + await _lyricManager.DeleteLyricsAsync(audio).ConfigureAwait(false); + return NoContent(); + } + + /// <summary> + /// Search remote lyrics. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <response code="200">Lyrics retrieved.</response> + /// <response code="404">Item not found.</response> + /// <returns>An array of <see cref="RemoteLyricInfo"/>.</returns> + [HttpGet("Audio/{itemId}/RemoteSearch/Lyrics")] + [Authorize(Policy = Policies.LyricManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<IReadOnlyList<RemoteLyricInfoDto>>> SearchRemoteLyrics([FromRoute, Required] Guid itemId) + { + var audio = _libraryManager.GetItemById<Audio>(itemId); + if (audio is null) + { + return NotFound(); + } + + var results = await _lyricManager.SearchLyricsAsync(audio, false, CancellationToken.None).ConfigureAwait(false); + return Ok(results); + } + + /// <summary> + /// Downloads a remote lyric. + /// </summary> + /// <param name="itemId">The item id.</param> + /// <param name="lyricId">The lyric id.</param> + /// <response code="200">Lyric downloaded.</response> + /// <response code="404">Item not found.</response> + /// <returns>A <see cref="NoContentResult"/>.</returns> + [HttpPost("Audio/{itemId}/RemoteSearch/Lyrics/{lyricId}")] + [Authorize(Policy = Policies.LyricManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<LyricDto>> DownloadRemoteLyrics( + [FromRoute, Required] Guid itemId, + [FromRoute, Required] string lyricId) + { + var audio = _libraryManager.GetItemById<Audio>(itemId); + if (audio is null) + { + return NotFound(); + } + + var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(audio, lyricId, CancellationToken.None).ConfigureAwait(false); + if (downloadedLyrics is null) + { + return NotFound(); + } + + _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + return Ok(downloadedLyrics); + } + + /// <summary> + /// Gets the remote lyrics. + /// </summary> + /// <param name="lyricId">The remote provider item id.</param> + /// <response code="200">File returned.</response> + /// <response code="404">Lyric not found.</response> + /// <returns>A <see cref="FileStreamResult"/> with the lyric file.</returns> + [HttpGet("Providers/Lyrics/{lyricId}")] + [Authorize(Policy = Policies.LyricManagement)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task<ActionResult<LyricDto>> GetRemoteLyrics([FromRoute, Required] string lyricId) + { + var result = await _lyricManager.GetRemoteLyricsAsync(lyricId, CancellationToken.None).ConfigureAwait(false); + if (result is null) + { + return NotFound(); + } + + return Ok(result); + } +} diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index bea545cfd..742012b71 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -64,8 +64,9 @@ public class MediaInfoController : BaseJellyfinApiController /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns> [HttpGet("Items/{itemId}/PlaybackInfo")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId) + public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId) { + userId = RequestHelpers.GetUserId(User, userId); return await _mediaInfoHelper.GetPlaybackInfo( itemId, userId) diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 921cc6031..0e7c3f155 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -174,7 +174,7 @@ public class PlaylistsController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status404NotFound)] public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems( [FromRoute, Required] Guid playlistId, - [FromQuery, Required] Guid userId, + [FromQuery] Guid? userId, [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, @@ -183,15 +183,16 @@ public class PlaylistsController : BaseJellyfinApiController [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes) { + userId = RequestHelpers.GetUserId(User, userId); var playlist = (Playlist)_libraryManager.GetItemById(playlistId); if (playlist is null) { return NotFound(); } - var user = userId.IsEmpty() + var user = userId.IsNullOrEmpty() ? null - : _userManager.GetUserById(userId); + : _userManager.GetUserById(userId.Value); var items = playlist.GetManageableItems().ToArray(); var count = items.Length; diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index 49ca058bd..cc2a630e1 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -11,7 +11,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Extensions; using Jellyfin.Api.Models.SubtitleDtos; using MediaBrowser.Common.Api; @@ -162,17 +161,17 @@ public class SubtitleController : BaseJellyfinApiController /// <summary> /// Gets the remote subtitles. /// </summary> - /// <param name="id">The item id.</param> + /// <param name="subtitleId">The item id.</param> /// <response code="200">File returned.</response> /// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns> - [HttpGet("Providers/Subtitles/Subtitles/{id}")] + [HttpGet("Providers/Subtitles/Subtitles/{subtitleId}")] [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] [Produces(MediaTypeNames.Application.Octet)] [ProducesFile("text/*")] - public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string id) + public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string subtitleId) { - var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false); + var result = await _subtitleManager.GetRemoteSubtitles(subtitleId, CancellationToken.None).ConfigureAwait(false); return File(result.Stream, MimeTypes.GetMimeType("file." + result.Format)); } @@ -407,22 +406,29 @@ public class SubtitleController : BaseJellyfinApiController [FromBody, Required] UploadSubtitleDto body) { var video = (Video)_libraryManager.GetItemById(itemId); - var stream = new CryptoStream(Request.Body, new FromBase64Transform(), CryptoStreamMode.Read); - await using (stream.ConfigureAwait(false)) - { - await _subtitleManager.UploadSubtitle( - video, - new SubtitleResponse - { - Format = body.Format, - Language = body.Language, - IsForced = body.IsForced, - IsHearingImpaired = body.IsHearingImpaired, - Stream = stream - }).ConfigureAwait(false); - _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); - return NoContent(); + var bytes = Encoding.UTF8.GetBytes(body.Data); + var memoryStream = new MemoryStream(bytes, 0, bytes.Length, false, true); + await using (memoryStream.ConfigureAwait(false)) + { + using var transform = new FromBase64Transform(); + var stream = new CryptoStream(memoryStream, transform, CryptoStreamMode.Read); + await using (stream.ConfigureAwait(false)) + { + await _subtitleManager.UploadSubtitle( + video, + new SubtitleResponse + { + Format = body.Format, + Language = body.Language, + IsForced = body.IsForced, + IsHearingImpaired = body.IsHearingImpaired, + Stream = stream + }).ConfigureAwait(false); + _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); + + return NoContent(); + } } } diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 3d4df0386..6c5ce4715 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -188,16 +188,24 @@ public class SystemController : BaseJellyfinApiController /// <param name="name">The name of the log file to get.</param> /// <response code="200">Log file retrieved.</response> /// <response code="403">User does not have permission to get log files.</response> + /// <response code="404">Could not find a log file with the name.</response> /// <returns>The log file.</returns> [HttpGet("Logs/Log")] [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesFile(MediaTypeNames.Text.Plain)] public ActionResult GetLogFile([FromQuery, Required] string name) { - var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath) - .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + var file = _fileSystem + .GetFiles(_appPaths.LogDirectoryPath) + .FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + + if (file is null) + { + return NotFound("Log file not found."); + } // For older files, assume fully static var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite; diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index 264e0a3db..e3bfd4ea9 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -18,6 +18,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; +using MediaBrowser.Model.Lyrics; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -539,48 +540,4 @@ public class UserLibraryController : BaseJellyfinApiController return _userDataRepository.GetUserDataDto(item, user); } - - /// <summary> - /// Gets an item's lyrics. - /// </summary> - /// <param name="userId">User id.</param> - /// <param name="itemId">Item id.</param> - /// <response code="200">Lyrics returned.</response> - /// <response code="404">Something went wrong. No Lyrics will be returned.</response> - /// <returns>An <see cref="OkResult"/> containing the item's lyrics.</returns> - [HttpGet("Users/{userId}/Items/{itemId}/Lyrics")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<LyricResponse>> GetLyrics([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) - { - var user = _userManager.GetUserById(userId); - - if (user is null) - { - return NotFound(); - } - - var item = itemId.IsEmpty() - ? _libraryManager.GetUserRootFolder() - : _libraryManager.GetItemById(itemId); - - 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 result = await _lyricManager.GetLyrics(item).ConfigureAwait(false); - if (result is not null) - { - return Ok(result); - } - - return NotFound(); - } } diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index e6c319869..b3029d6fa 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -458,10 +458,8 @@ public class VideosController : BaseJellyfinApiController return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically"); } - var outputPath = state.OutputFilePath; - // Static stream - if (@static.HasValue && @static.Value) + if (@static.HasValue && @static.Value && !(state.MediaSource.VideoType == VideoType.BluRay || state.MediaSource.VideoType == VideoType.Dvd)) { var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); @@ -478,7 +476,7 @@ public class VideosController : BaseJellyfinApiController // Need to start ffmpeg (because media can't be returned directly) var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); - var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, "superfast"); + var ffmpegCommandLineArguments = _encodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, "superfast"); return await FileStreamResponseHelpers.GetTranscodedFile( state, isHeadRequest, diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index fa81fc284..f8d89119a 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -211,19 +211,8 @@ public class DynamicHlsHelper var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main"); sdrVideoUrl += "&AllowVideoStreamCopy=false"; - var sdrOutputVideoBitrate = _encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); - var sdrOutputAudioBitrate = 0; - if (EncodingHelper.LosslessAudioCodecs.Contains(state.VideoRequest.AudioCodec, StringComparison.OrdinalIgnoreCase)) - { - sdrOutputAudioBitrate = state.AudioStream.BitRate ?? 0; - } - else - { - sdrOutputAudioBitrate = _encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream, state.OutputAudioChannels) ?? 0; - } - - var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; - AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); + // HACK: Use the same bitrate so that the client can choose by other attributes, such as color range. + AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup); // Restore the video codec state.OutputVideoCodec = "copy"; @@ -325,6 +314,7 @@ public class DynamicHlsHelper if (state.VideoStream is not null && state.VideoStream.VideoRange != VideoRange.Unknown) { var videoRange = state.VideoStream.VideoRange; + var videoRangeType = state.VideoStream.VideoRangeType; if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)) { if (videoRange == VideoRange.SDR) @@ -334,7 +324,14 @@ public class DynamicHlsHelper if (videoRange == VideoRange.HDR) { - builder.Append(",VIDEO-RANGE=PQ"); + if (videoRangeType == VideoRangeType.HLG) + { + builder.Append(",VIDEO-RANGE=HLG"); + } + else + { + builder.Append(",VIDEO-RANGE=PQ"); + } } } else diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs index 5385979d4..cb178a61d 100644 --- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs +++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs @@ -93,9 +93,7 @@ public static class FileStreamResponseHelpers return new OkResult(); } - var transcodingLock = transcodeManager.GetTranscodingLock(outputPath); - await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); - try + using (await transcodeManager.LockAsync(outputPath, cancellationTokenSource.Token).ConfigureAwait(false)) { TranscodingJob? job; if (!File.Exists(outputPath)) @@ -117,9 +115,5 @@ public static class FileStreamResponseHelpers var stream = new ProgressiveFileStream(outputPath, job, transcodeManager); return new FileStreamResult(stream, contentType); } - finally - { - transcodingLock.Release(); - } } } diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 7a3842a9f..bfe71fd87 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -225,7 +225,7 @@ public static class StreamingHelpers var ext = string.IsNullOrWhiteSpace(state.OutputContainer) ? GetOutputFileExtension(state, mediaSource) - : ("." + state.OutputContainer); + : ("." + GetContainerFileExtension(state.OutputContainer)); state.OutputFilePath = GetOutputFilePath(state, ext, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId); @@ -559,4 +559,23 @@ public static class StreamingHelpers } } } + + /// <summary> + /// Parses the container into its file extension. + /// </summary> + /// <param name="container">The container.</param> + private static string? GetContainerFileExtension(string? container) + { + if (string.Equals(container, "mpegts", StringComparison.OrdinalIgnoreCase)) + { + return "ts"; + } + + if (string.Equals(container, "matroska", StringComparison.OrdinalIgnoreCase)) + { + return "mkv"; + } + + return container; + } } diff --git a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs deleted file mode 100644 index cbc3548b1..000000000 --- a/Jellyfin.Api/Models/LiveTvDtos/ChannelMappingOptionsDto.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Dto; - -namespace Jellyfin.Api.Models.LiveTvDtos; - -/// <summary> -/// Channel mapping options dto. -/// </summary> -public class ChannelMappingOptionsDto -{ - /// <summary> - /// Gets or sets list of tuner channels. - /// </summary> - public required IReadOnlyList<TunerChannelMapping> TunerChannels { get; set; } - - /// <summary> - /// Gets or sets list of provider channels. - /// </summary> - public required IReadOnlyList<NameIdPair> ProviderChannels { get; set; } - - /// <summary> - /// Gets or sets list of mappings. - /// </summary> - public IReadOnlyList<NameValuePair> Mappings { get; set; } = Array.Empty<NameValuePair>(); - - /// <summary> - /// Gets or sets provider name. - /// </summary> - public string? ProviderName { get; set; } -} diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs index 6a30de5e6..8482b1cf1 100644 --- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs +++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs @@ -22,7 +22,7 @@ public class GetProgramsDto /// <summary> /// Gets or sets optional. Filter by user id. /// </summary> - public Guid UserId { get; set; } + public Guid? UserId { get; set; } /// <summary> /// Gets or sets the minimum premiere start date. diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs index acd3f29e3..12ce19368 100644 --- a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs +++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json.Converters; @@ -50,6 +51,18 @@ public class ClientCapabilitiesDto /// </summary> public string? IconUrl { get; set; } +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + // TODO: Remove after 10.9 + [Obsolete("Unused")] + [DefaultValue(false)] + public bool? SupportsContentUploading { get; set; } + + // TODO: Remove after 10.9 + [Obsolete("Unused")] + [DefaultValue(false)] + public bool? SupportsSync { get; set; } +#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + /// <summary> /// Convert the dto to the full <see cref="ClientCapabilities"/> model. /// </summary> |
