Merge remote-tracking branch 'upstream/master' into fonts

This commit is contained in:
nyanmisaka
2020-11-08 19:11:54 +08:00
1054 changed files with 20114 additions and 27718 deletions

View File

@@ -1,7 +1,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Queries;
using MediaBrowser.Model.Activity;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
@@ -39,19 +39,19 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
[HttpGet("Entries")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<ActivityLogEntry>> GetLogEntries(
public async Task<ActionResult<QueryResult<ActivityLogEntry>>> GetLogEntries(
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] DateTime? minDate,
[FromQuery] bool? hasUserId)
{
var filterFunc = new Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>>(
entries => entries.Where(entry => entry.DateCreated >= minDate
&& (!hasUserId.HasValue || (hasUserId.Value
? entry.UserId != Guid.Empty
: entry.UserId == Guid.Empty))));
return _activityManager.GetPagedResult(filterFunc, startIndex, limit);
return await _activityManager.GetPagedResultAsync(new ActivityLogQuery
{
StartIndex = startIndex,
Limit = limit,
MinDate = minDate,
HasUserId = hasUserId
}).ConfigureAwait(false);
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
@@ -52,7 +53,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Albums/{albumId}/Similar")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums(
[FromRoute] string albumId,
[FromRoute, Required] string albumId,
[FromQuery] Guid? userId,
[FromQuery] string? excludeArtistIds,
[FromQuery] int? limit)
@@ -84,7 +85,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Artists/{artistId}/Similar")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists(
[FromRoute] string artistId,
[FromRoute, Required] string artistId,
[FromQuery] Guid? userId,
[FromQuery] string? excludeArtistIds,
[FromQuery] int? limit)

View File

@@ -65,7 +65,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Keys")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult CreateKey([FromQuery, Required] string? app)
public ActionResult CreateKey([FromQuery, Required] string app)
{
_authRepo.Create(new AuthenticationInfo
{
@@ -88,7 +88,7 @@ namespace Jellyfin.Api.Controllers
[HttpDelete("Keys/{key}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult RevokeKey([FromRoute, Required] string? key)
public ActionResult RevokeKey([FromRoute, Required] string key)
{
_sessionManager.RevokeToken(key);
return NoContent();

View File

@@ -1,13 +1,16 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Entities;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -53,7 +56,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
/// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
/// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="filters">Optional. Specify additional filters to apply.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
@@ -88,7 +91,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? fields,
[FromQuery] string? excludeItemTypes,
[FromQuery] string? includeItemTypes,
[FromQuery] string? filters,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes,
[FromQuery] string? genres,
@@ -98,7 +101,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] string? person,
[FromQuery] string? personIds,
[FromQuery] string? personTypes,
@@ -144,9 +147,9 @@ namespace Jellyfin.Api.Controllers
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
Tags = RequestHelpers.Split(tags, ',', true),
OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
Genres = RequestHelpers.Split(genres, ',', true),
Tags = RequestHelpers.Split(tags, '|', true),
OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
Genres = RequestHelpers.Split(genres, '|', true),
GenreIds = RequestHelpers.GetGuids(genreIds),
StudioIds = RequestHelpers.GetGuids(studioIds),
Person = person,
@@ -187,7 +190,7 @@ namespace Jellyfin.Api.Controllers
}).Where(i => i != null).Select(i => i!.Id).ToArray();
}
foreach (var filter in RequestHelpers.GetFilters(filters))
foreach (var filter in filters)
{
switch (filter)
{
@@ -262,7 +265,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
/// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
/// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="filters">Optional. Specify additional filters to apply.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
@@ -297,7 +300,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? fields,
[FromQuery] string? excludeItemTypes,
[FromQuery] string? includeItemTypes,
[FromQuery] string? filters,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes,
[FromQuery] string? genres,
@@ -307,7 +310,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] string? person,
[FromQuery] string? personIds,
[FromQuery] string? personTypes,
@@ -353,9 +356,9 @@ namespace Jellyfin.Api.Controllers
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
Tags = RequestHelpers.Split(tags, ',', true),
OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
Genres = RequestHelpers.Split(genres, ',', true),
Tags = RequestHelpers.Split(tags, '|', true),
OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
Genres = RequestHelpers.Split(genres, '|', true),
GenreIds = RequestHelpers.GetGuids(genreIds),
StudioIds = RequestHelpers.GetGuids(studioIds),
Person = person,
@@ -396,7 +399,7 @@ namespace Jellyfin.Api.Controllers
}).Where(i => i != null).Select(i => i!.Id).ToArray();
}
foreach (var filter in RequestHelpers.GetFilters(filters))
foreach (var filter in filters)
{
switch (filter)
{
@@ -469,7 +472,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the artist.</returns>
[HttpGet("{name}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<BaseItemDto> GetArtistByName([FromRoute] string name, [FromQuery] Guid? userId)
public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId)
{
var dtoOptions = new DtoOptions().AddClientFields(Request);

View File

@@ -1,93 +1,34 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.StreamingDtos;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Dlna;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
namespace Jellyfin.Api.Controllers
{
/// <summary>
/// The audio controller.
/// </summary>
// TODO: In order to autheneticate this in the future, Dlna playback will require updating
// TODO: In order to authenticate this in the future, Dlna playback will require updating
public class AudioController : BaseJellyfinApiController
{
private readonly IDlnaManager _dlnaManager;
private readonly IAuthorizationContext _authContext;
private readonly IUserManager _userManager;
private readonly ILibraryManager _libraryManager;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IMediaEncoder _mediaEncoder;
private readonly IFileSystem _fileSystem;
private readonly ISubtitleEncoder _subtitleEncoder;
private readonly IConfiguration _configuration;
private readonly IDeviceManager _deviceManager;
private readonly TranscodingJobHelper _transcodingJobHelper;
private readonly IHttpClientFactory _httpClientFactory;
private readonly AudioHelper _audioHelper;
private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Progressive;
/// <summary>
/// Initializes a new instance of the <see cref="AudioController"/> class.
/// </summary>
/// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param>
/// <param name="userManger">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param>
/// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param>
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
/// <param name="transcodingJobHelper">The <see cref="TranscodingJobHelper"/> singleton.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
public AudioController(
IDlnaManager dlnaManager,
IUserManager userManger,
IAuthorizationContext authorizationContext,
ILibraryManager libraryManager,
IMediaSourceManager mediaSourceManager,
IServerConfigurationManager serverConfigurationManager,
IMediaEncoder mediaEncoder,
IFileSystem fileSystem,
ISubtitleEncoder subtitleEncoder,
IConfiguration configuration,
IDeviceManager deviceManager,
TranscodingJobHelper transcodingJobHelper,
IHttpClientFactory httpClientFactory)
/// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param>
public AudioController(AudioHelper audioHelper)
{
_dlnaManager = dlnaManager;
_authContext = authorizationContext;
_userManager = userManger;
_libraryManager = libraryManager;
_mediaSourceManager = mediaSourceManager;
_serverConfigurationManager = serverConfigurationManager;
_mediaEncoder = mediaEncoder;
_fileSystem = fileSystem;
_subtitleEncoder = subtitleEncoder;
_configuration = configuration;
_deviceManager = deviceManager;
_transcodingJobHelper = transcodingJobHelper;
_httpClientFactory = httpClientFactory;
_audioHelper = audioHelper;
}
/// <summary>
@@ -144,13 +85,14 @@ namespace Jellyfin.Api.Controllers
/// <param name="streamOptions">Optional. The streaming options.</param>
/// <response code="200">Audio stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("{itemId}/{stream=stream}.{container?}", Name = "GetAudioStreamByContainer")]
[HttpGet("{itemId}/stream.{container:required}", Name = "GetAudioStreamByContainer")]
[HttpGet("{itemId}/stream", Name = "GetAudioStream")]
[HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadAudioStreamByContainer")]
[HttpHead("{itemId}/stream.{container:required}", Name = "HeadAudioStreamByContainer")]
[HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesAudioFile]
public async Task<ActionResult> GetAudioStream(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromRoute] string? container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
@@ -200,10 +142,6 @@ namespace Jellyfin.Api.Controllers
[FromQuery] EncodingContext? context,
[FromQuery] Dictionary<string, string>? streamOptions)
{
bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
var cancellationTokenSource = new CancellationTokenSource();
StreamingRequestDto streamingRequest = new StreamingRequestDto
{
Id = itemId,
@@ -257,97 +195,7 @@ namespace Jellyfin.Api.Controllers
StreamOptions = streamOptions
};
using var state = await StreamingHelpers.GetStreamingState(
streamingRequest,
Request,
_authContext,
_mediaSourceManager,
_userManager,
_libraryManager,
_serverConfigurationManager,
_mediaEncoder,
_fileSystem,
_subtitleEncoder,
_configuration,
_dlnaManager,
_deviceManager,
_transcodingJobHelper,
_transcodingJobType,
cancellationTokenSource.Token)
.ConfigureAwait(false);
if (@static.HasValue && @static.Value && state.DirectStreamProvider != null)
{
StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager);
await new ProgressiveFileCopier(state.DirectStreamProvider, null, _transcodingJobHelper, CancellationToken.None)
{
AllowEndOfFile = false
}.WriteToAsync(Response.Body, CancellationToken.None)
.ConfigureAwait(false);
// TODO (moved from MediaBrowser.Api): Don't hardcode contentType
return File(Response.Body, MimeTypes.GetMimeType("file.ts")!);
}
// Static remote stream
if (@static.HasValue && @static.Value && state.InputProtocol == MediaProtocol.Http)
{
StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager);
var httpClient = _httpClientFactory.CreateClient();
return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, this, httpClient).ConfigureAwait(false);
}
if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File)
{
return BadRequest($"Input protocol {state.InputProtocol} cannot be streamed statically");
}
var outputPath = state.OutputFilePath;
var outputPathExists = System.IO.File.Exists(outputPath);
var transcodingJob = _transcodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive);
var isTranscodeCached = outputPathExists && transcodingJob != null;
StreamingHelpers.AddDlnaHeaders(state, Response.Headers, (@static.HasValue && @static.Value) || isTranscodeCached, startTimeTicks, Request, _dlnaManager);
// Static stream
if (@static.HasValue && @static.Value)
{
var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath);
if (state.MediaSource.IsInfiniteStream)
{
await new ProgressiveFileCopier(state.MediaPath, null, _transcodingJobHelper, CancellationToken.None)
{
AllowEndOfFile = false
}.WriteToAsync(Response.Body, CancellationToken.None)
.ConfigureAwait(false);
return File(Response.Body, contentType);
}
return FileStreamResponseHelpers.GetStaticFileResult(
state.MediaPath,
contentType,
isHeadRequest,
this);
}
// Need to start ffmpeg (because media can't be returned directly)
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
var encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
var ffmpegCommandLineArguments = encodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath);
return await FileStreamResponseHelpers.GetTranscodedFile(
state,
isHeadRequest,
this,
_transcodingJobHelper,
ffmpegCommandLineArguments,
Request,
_transcodingJobType,
cancellationTokenSource).ConfigureAwait(false);
return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
}
}
}

View File

@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -90,7 +92,7 @@ namespace Jellyfin.Api.Controllers
/// <response code="200">Channel features returned.</response>
/// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
[HttpGet("{channelId}/Features")]
public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute] string channelId)
public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] string channelId)
{
return _channelManager.GetChannelFeatures(channelId);
}
@@ -104,7 +106,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param>
/// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="filters">Optional. Specify additional filters to apply.</param>
/// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <response code="200">Channel items returned.</response>
@@ -114,13 +116,13 @@ namespace Jellyfin.Api.Controllers
/// </returns>
[HttpGet("{channelId}/Items")]
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems(
[FromRoute] Guid channelId,
[FromRoute, Required] Guid channelId,
[FromQuery] Guid? folderId,
[FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? sortOrder,
[FromQuery] string? filters,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] string? sortBy,
[FromQuery] string? fields)
{
@@ -139,7 +141,7 @@ namespace Jellyfin.Api.Controllers
.AddItemFields(fields)
};
foreach (var filter in RequestHelpers.GetFilters(filters))
foreach (var filter in filters)
{
switch (filter)
{
@@ -182,7 +184,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="userId">Optional. User Id.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="filters">Optional. Specify additional filters to apply.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param>
/// <response code="200">Latest channel items returned.</response>
@@ -195,7 +197,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? filters,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] string? fields,
[FromQuery] string? channelIds)
{
@@ -216,7 +218,7 @@ namespace Jellyfin.Api.Controllers
.AddItemFields(fields)
};
foreach (var filter in RequestHelpers.GetFilters(filters))
foreach (var filter in filters)
{
switch (filter)
{

View File

@@ -1,5 +1,6 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
@@ -51,7 +52,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns>
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<CollectionCreationResult> CreateCollection(
public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
[FromQuery] string? name,
[FromQuery] string? ids,
[FromQuery] Guid? parentId,
@@ -59,14 +60,14 @@ namespace Jellyfin.Api.Controllers
{
var userId = _authContext.GetAuthorizationInfo(Request).UserId;
var item = _collectionManager.CreateCollection(new CollectionCreationOptions
var item = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions
{
IsLocked = isLocked,
Name = name,
ParentId = parentId,
ItemIdList = RequestHelpers.Split(ids, ',', true),
UserIds = new[] { userId }
});
}).ConfigureAwait(false);
var dtoOptions = new DtoOptions().AddClientFields(Request);
@@ -82,14 +83,14 @@ namespace Jellyfin.Api.Controllers
/// Adds items to a collection.
/// </summary>
/// <param name="collectionId">The collection id.</param>
/// <param name="itemIds">Item ids, comma delimited.</param>
/// <param name="ids">Item ids, comma delimited.</param>
/// <response code="204">Items added to collection.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult AddToCollection([FromRoute] Guid collectionId, [FromQuery, Required] string? itemIds)
public async Task<ActionResult> AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids)
{
_collectionManager.AddToCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(true);
return NoContent();
}
@@ -97,14 +98,14 @@ namespace Jellyfin.Api.Controllers
/// Removes items from a collection.
/// </summary>
/// <param name="collectionId">The collection id.</param>
/// <param name="itemIds">Item ids, comma delimited.</param>
/// <param name="ids">Item ids, comma delimited.</param>
/// <response code="204">Items removed from collection.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpDelete("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult RemoveFromCollection([FromRoute] Guid collectionId, [FromQuery, Required] string? itemIds)
public async Task<ActionResult> RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids)
{
_collectionManager.RemoveFromCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(false);
return NoContent();
}
}

View File

@@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.Net.Mime;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Models.ConfigurationDtos;
using MediaBrowser.Common.Json;
@@ -73,7 +75,8 @@ namespace Jellyfin.Api.Controllers
/// <returns>Configuration.</returns>
[HttpGet("Configuration/{key}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<object> GetNamedConfiguration([FromRoute] string? key)
[ProducesFile(MediaTypeNames.Application.Json)]
public ActionResult<object> GetNamedConfiguration([FromRoute, Required] string key)
{
return _configurationManager.GetConfiguration(key);
}
@@ -87,7 +90,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Configuration/{key}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> UpdateNamedConfiguration([FromRoute] string? key)
public async Task<ActionResult> UpdateNamedConfiguration([FromRoute, Required] string key)
{
var configurationType = _configurationManager.GetConfigurationType(key);
var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType, _serializerOptions).ConfigureAwait(false);
@@ -117,7 +120,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("MediaEncoder/Path")]
[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateMediaEncoderPath([FromForm, Required] MediaEncoderPathDto mediaEncoderPath)
public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath)
{
_mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
return NoContent();

View File

@@ -2,6 +2,8 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Mime;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Models;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller;
@@ -15,6 +17,7 @@ using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
namespace Jellyfin.Api.Controllers
{
@@ -26,38 +29,20 @@ namespace Jellyfin.Api.Controllers
{
private readonly ILogger<DashboardController> _logger;
private readonly IServerApplicationHost _appHost;
private readonly IConfiguration _appConfig;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IResourceFileManager _resourceFileManager;
/// <summary>
/// Initializes a new instance of the <see cref="DashboardController"/> class.
/// </summary>
/// <param name="logger">Instance of <see cref="ILogger{DashboardController}"/> interface.</param>
/// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
/// <param name="appConfig">Instance of <see cref="IConfiguration"/> interface.</param>
/// <param name="resourceFileManager">Instance of <see cref="IResourceFileManager"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
public DashboardController(
ILogger<DashboardController> logger,
IServerApplicationHost appHost,
IConfiguration appConfig,
IResourceFileManager resourceFileManager,
IServerConfigurationManager serverConfigurationManager)
IServerApplicationHost appHost)
{
_logger = logger;
_appHost = appHost;
_appConfig = appConfig;
_resourceFileManager = resourceFileManager;
_serverConfigurationManager = serverConfigurationManager;
}
/// <summary>
/// Gets the path of the directory containing the static web interface content, or null if the server is not
/// hosting the web client.
/// </summary>
private string? WebClientUiPath => GetWebClientUiPath(_appConfig, _serverConfigurationManager);
/// <summary>
/// Gets the configuration pages.
/// </summary>
@@ -123,6 +108,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("web/ConfigurationPage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesFile(MediaTypeNames.Text.Html, "application/x-javascript")]
public ActionResult GetDashboardConfigurationPage([FromQuery] string? name)
{
IPlugin? plugin = null;
@@ -169,87 +155,6 @@ namespace Jellyfin.Api.Controllers
return NotFound();
}
/// <summary>
/// Gets the robots.txt.
/// </summary>
/// <response code="200">Robots.txt returned.</response>
/// <returns>The robots.txt.</returns>
[HttpGet("robots.txt")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ApiExplorerSettings(IgnoreApi = true)]
public ActionResult GetRobotsTxt()
{
return GetWebClientResource("robots.txt");
}
/// <summary>
/// Gets a resource from the web client.
/// </summary>
/// <param name="resourceName">The resource name.</param>
/// <response code="200">Web client returned.</response>
/// <response code="404">Server does not host a web client.</response>
/// <returns>The resource.</returns>
[HttpGet("web/{*resourceName}")]
[ApiExplorerSettings(IgnoreApi = true)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult GetWebClientResource([FromRoute] string resourceName)
{
if (!_appConfig.HostWebClient() || WebClientUiPath == null)
{
return NotFound("Server does not host a web client.");
}
var path = resourceName;
var basePath = WebClientUiPath;
var requestPathAndQuery = Request.GetEncodedPathAndQuery();
// Bounce them to the startup wizard if it hasn't been completed yet
if (!_serverConfigurationManager.Configuration.IsStartupWizardCompleted
&& !requestPathAndQuery.Contains("wizard", StringComparison.OrdinalIgnoreCase)
&& requestPathAndQuery.Contains("index", StringComparison.OrdinalIgnoreCase))
{
return Redirect("index.html?start=wizard#!/wizardstart.html");
}
var stream = new FileStream(_resourceFileManager.GetResourcePath(basePath, path), FileMode.Open, FileAccess.Read);
return File(stream, MimeTypes.GetMimeType(path));
}
/// <summary>
/// Gets the favicon.
/// </summary>
/// <response code="200">Favicon.ico returned.</response>
/// <returns>The favicon.</returns>
[HttpGet("favicon.ico")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ApiExplorerSettings(IgnoreApi = true)]
public ActionResult GetFavIcon()
{
return GetWebClientResource("favicon.ico");
}
/// <summary>
/// Gets the path of the directory containing the static web interface content.
/// </summary>
/// <param name="appConfig">The app configuration.</param>
/// <param name="serverConfigManager">The server configuration manager.</param>
/// <returns>The directory path, or null if the server is not hosting the web client.</returns>
public static string? GetWebClientUiPath(IConfiguration appConfig, IServerConfigurationManager serverConfigManager)
{
if (!appConfig.HostWebClient())
{
return null;
}
if (!string.IsNullOrEmpty(serverConfigManager.Configuration.DashboardSourcePath))
{
return serverConfigManager.Configuration.DashboardSourcePath;
}
return serverConfigManager.ApplicationPaths.WebPath;
}
private IEnumerable<ConfigurationPageInfo> GetConfigPages(IPlugin plugin)
{
return GetPluginPages(plugin).Select(i => new ConfigurationPageInfo(plugin, i.Item1));

View File

@@ -15,7 +15,7 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Devices Controller.
/// </summary>
[Authorize(Policy = Policies.DefaultAuthorization)]
[Authorize(Policy = Policies.RequiresElevation)]
public class DevicesController : BaseJellyfinApiController
{
private readonly IDeviceManager _deviceManager;
@@ -46,9 +46,8 @@ namespace Jellyfin.Api.Controllers
/// <response code="200">Devices retrieved.</response>
/// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
[HttpGet]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery, Required] Guid? userId)
public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
{
var deviceQuery = new DeviceQuery { SupportsSync = supportsSync, UserId = userId ?? Guid.Empty };
return _deviceManager.GetDevices(deviceQuery);
@@ -62,10 +61,9 @@ namespace Jellyfin.Api.Controllers
/// <response code="404">Device not found.</response>
/// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
[HttpGet("Info")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, Required] string? id)
public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, Required] string id)
{
var deviceInfo = _deviceManager.GetDevice(id);
if (deviceInfo == null)
@@ -84,10 +82,9 @@ namespace Jellyfin.Api.Controllers
/// <response code="404">Device not found.</response>
/// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
[HttpGet("Options")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, Required] string? id)
public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, Required] string id)
{
var deviceInfo = _deviceManager.GetDeviceOptions(id);
if (deviceInfo == null)
@@ -107,11 +104,10 @@ namespace Jellyfin.Api.Controllers
/// <response code="404">Device not found.</response>
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
[HttpPost("Options")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateDeviceOptions(
[FromQuery, Required] string? id,
[FromQuery, Required] string id,
[FromBody, Required] DeviceOptions deviceOptions)
{
var existingDeviceOptions = _deviceManager.GetDeviceOptions(id);
@@ -134,7 +130,7 @@ namespace Jellyfin.Api.Controllers
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DeleteDevice([FromQuery, Required] string? id)
public ActionResult DeleteDevice([FromQuery, Required] string id)
{
var existingDevice = _deviceManager.GetDevice(id);
if (existingDevice == null)

View File

@@ -43,9 +43,9 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
public ActionResult<DisplayPreferencesDto> GetDisplayPreferences(
[FromRoute] string? displayPreferencesId,
[FromQuery] [Required] Guid userId,
[FromQuery] [Required] string? client)
[FromRoute, Required] string displayPreferencesId,
[FromQuery, Required] Guid userId,
[FromQuery, Required] string client)
{
var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client);
var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client);
@@ -81,6 +81,9 @@ namespace Jellyfin.Api.Controllers
dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
// This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
_displayPreferencesManager.SaveChanges();
return dto;
}
@@ -97,9 +100,9 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "displayPreferencesId", Justification = "Imported from ServiceStack")]
public ActionResult UpdateDisplayPreferences(
[FromRoute] string? displayPreferencesId,
[FromRoute, Required] string displayPreferencesId,
[FromQuery, Required] Guid userId,
[FromQuery, Required] string? client,
[FromQuery, Required] string client,
[FromBody, Required] DisplayPreferencesDto displayPreferences)
{
HomeSectionType[] defaults =
@@ -153,7 +156,6 @@ namespace Jellyfin.Api.Controllers
{
var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Parse(key.Substring("landing-".Length)), existingDisplayPreferences.Client);
itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType);
_displayPreferencesManager.SaveChanges(itemPreferences);
}
var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Empty, existingDisplayPreferences.Client);
@@ -167,8 +169,7 @@ namespace Jellyfin.Api.Controllers
itemPrefs.ViewType = viewType;
}
_displayPreferencesManager.SaveChanges(existingDisplayPreferences);
_displayPreferencesManager.SaveChanges(itemPrefs);
_displayPreferencesManager.SaveChanges();
return NoContent();
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Constants;
using MediaBrowser.Controller.Dlna;
using MediaBrowser.Model.Dlna;
@@ -59,7 +60,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Profiles/{profileId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<DeviceProfile> GetProfile([FromRoute] string profileId)
public ActionResult<DeviceProfile> GetProfile([FromRoute, Required] string profileId)
{
var profile = _dlnaManager.GetProfile(profileId);
if (profile == null)
@@ -80,7 +81,7 @@ namespace Jellyfin.Api.Controllers
[HttpDelete("Profiles/{profileId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DeleteProfile([FromRoute] string profileId)
public ActionResult DeleteProfile([FromRoute, Required] string profileId)
{
var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
if (existingDeviceProfile == null)
@@ -117,7 +118,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Profiles/{profileId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateProfile([FromRoute] string profileId, [FromBody] DeviceProfile deviceProfile)
public ActionResult UpdateProfile([FromRoute, Required] string profileId, [FromBody] DeviceProfile deviceProfile)
{
var existingDeviceProfile = _dlnaManager.GetProfile(profileId);
if (existingDeviceProfile == null)

View File

@@ -1,6 +1,8 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net.Mime;
using System.Threading.Tasks;
using Emby.Dlna;
using Emby.Dlna.Main;
@@ -17,8 +19,6 @@ namespace Jellyfin.Api.Controllers
[Route("Dlna")]
public class DlnaServerController : BaseJellyfinApiController
{
private const string XMLContentType = "text/xml; charset=UTF-8";
private readonly IDlnaManager _dlnaManager;
private readonly IContentDirectory _contentDirectory;
private readonly IConnectionManager _connectionManager;
@@ -44,9 +44,10 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the description xml.</returns>
[HttpGet("{serverId}/description")]
[HttpGet("{serverId}/description.xml", Name = "GetDescriptionXml_2")]
[Produces(XMLContentType)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult GetDescriptionXml([FromRoute] string serverId)
[Produces(MediaTypeNames.Text.Xml)]
[ProducesFile(MediaTypeNames.Text.Xml)]
public ActionResult GetDescriptionXml([FromRoute, Required] string serverId)
{
var url = GetAbsoluteUri();
var serverAddress = url.Substring(0, url.IndexOf("/dlna/", StringComparison.OrdinalIgnoreCase));
@@ -61,11 +62,13 @@ namespace Jellyfin.Api.Controllers
/// <response code="200">Dlna content directory returned.</response>
/// <returns>An <see cref="OkResult"/> containing the dlna content directory xml.</returns>
[HttpGet("{serverId}/ContentDirectory")]
[HttpGet("{serverId}/ContentDirectory.xml", Name = "GetContentDirectory_2")]
[Produces(XMLContentType)]
[HttpGet("{serverId}/ContentDirectory/ContentDirectory", Name = "GetContentDirectory_2")]
[HttpGet("{serverId}/ContentDirectory/ContentDirectory.xml", Name = "GetContentDirectory_3")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Text.Xml)]
[ProducesFile(MediaTypeNames.Text.Xml)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
public ActionResult GetContentDirectory([FromRoute] string serverId)
public ActionResult GetContentDirectory([FromRoute, Required] string serverId)
{
return Ok(_contentDirectory.GetServiceXml());
}
@@ -74,13 +77,16 @@ namespace Jellyfin.Api.Controllers
/// Gets Dlna media receiver registrar xml.
/// </summary>
/// <param name="serverId">Server UUID.</param>
/// <response code="200">Dlna media receiver registrar xml returned.</response>
/// <returns>Dlna media receiver registrar xml.</returns>
[HttpGet("{serverId}/MediaReceiverRegistrar")]
[HttpGet("{serverId}/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_2")]
[Produces(XMLContentType)]
[HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar", Name = "GetMediaReceiverRegistrar_2")]
[HttpGet("{serverId}/MediaReceiverRegistrar/MediaReceiverRegistrar.xml", Name = "GetMediaReceiverRegistrar_3")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Text.Xml)]
[ProducesFile(MediaTypeNames.Text.Xml)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
public ActionResult GetMediaReceiverRegistrar([FromRoute] string serverId)
public ActionResult GetMediaReceiverRegistrar([FromRoute, Required] string serverId)
{
return Ok(_mediaReceiverRegistrar.GetServiceXml());
}
@@ -89,13 +95,16 @@ namespace Jellyfin.Api.Controllers
/// Gets Dlna media receiver registrar xml.
/// </summary>
/// <param name="serverId">Server UUID.</param>
/// <response code="200">Dlna media receiver registrar xml returned.</response>
/// <returns>Dlna media receiver registrar xml.</returns>
[HttpGet("{serverId}/ConnectionManager")]
[HttpGet("{serverId}/ConnectionManager.xml", Name = "GetConnectionManager_2")]
[Produces(XMLContentType)]
[HttpGet("{serverId}/ConnectionManager/ConnectionManager", Name = "GetConnectionManager_2")]
[HttpGet("{serverId}/ConnectionManager/ConnectionManager.xml", Name = "GetConnectionManager_3")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Text.Xml)]
[ProducesFile(MediaTypeNames.Text.Xml)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
public ActionResult GetConnectionManager([FromRoute] string serverId)
public ActionResult GetConnectionManager([FromRoute, Required] string serverId)
{
return Ok(_connectionManager.GetServiceXml());
}
@@ -104,9 +113,13 @@ namespace Jellyfin.Api.Controllers
/// Process a content directory control request.
/// </summary>
/// <param name="serverId">Server UUID.</param>
/// <response code="200">Request processed.</response>
/// <returns>Control response.</returns>
[HttpPost("{serverId}/ContentDirectory/Control")]
public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute] string serverId)
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Text.Xml)]
[ProducesFile(MediaTypeNames.Text.Xml)]
public async Task<ActionResult<ControlResponse>> ProcessContentDirectoryControlRequest([FromRoute, Required] string serverId)
{
return await ProcessControlRequestInternalAsync(serverId, Request.Body, _contentDirectory).ConfigureAwait(false);
}
@@ -115,9 +128,13 @@ namespace Jellyfin.Api.Controllers
/// Process a connection manager control request.
/// </summary>
/// <param name="serverId">Server UUID.</param>
/// <response code="200">Request processed.</response>
/// <returns>Control response.</returns>
[HttpPost("{serverId}/ConnectionManager/Control")]
public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute] string serverId)
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Text.Xml)]
[ProducesFile(MediaTypeNames.Text.Xml)]
public async Task<ActionResult<ControlResponse>> ProcessConnectionManagerControlRequest([FromRoute, Required] string serverId)
{
return await ProcessControlRequestInternalAsync(serverId, Request.Body, _connectionManager).ConfigureAwait(false);
}
@@ -126,9 +143,13 @@ namespace Jellyfin.Api.Controllers
/// Process a media receiver registrar control request.
/// </summary>
/// <param name="serverId">Server UUID.</param>
/// <response code="200">Request processed.</response>
/// <returns>Control response.</returns>
[HttpPost("{serverId}/MediaReceiverRegistrar/Control")]
public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute] string serverId)
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Text.Xml)]
[ProducesFile(MediaTypeNames.Text.Xml)]
public async Task<ActionResult<ControlResponse>> ProcessMediaReceiverRegistrarControlRequest([FromRoute, Required] string serverId)
{
return await ProcessControlRequestInternalAsync(serverId, Request.Body, _mediaReceiverRegistrar).ConfigureAwait(false);
}
@@ -137,11 +158,15 @@ namespace Jellyfin.Api.Controllers
/// Processes an event subscription request.
/// </summary>
/// <param name="serverId">Server UUID.</param>
/// <response code="200">Request processed.</response>
/// <returns>Event subscription response.</returns>
[HttpSubscribe("{serverId}/MediaReceiverRegistrar/Events")]
[HttpUnsubscribe("{serverId}/MediaReceiverRegistrar/Events")]
[ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Text.Xml)]
[ProducesFile(MediaTypeNames.Text.Xml)]
public ActionResult<EventSubscriptionResponse> ProcessMediaReceiverRegistrarEventRequest(string serverId)
{
return ProcessEventRequest(_mediaReceiverRegistrar);
@@ -151,11 +176,15 @@ namespace Jellyfin.Api.Controllers
/// Processes an event subscription request.
/// </summary>
/// <param name="serverId">Server UUID.</param>
/// <response code="200">Request processed.</response>
/// <returns>Event subscription response.</returns>
[HttpSubscribe("{serverId}/ContentDirectory/Events")]
[HttpUnsubscribe("{serverId}/ContentDirectory/Events")]
[ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Text.Xml)]
[ProducesFile(MediaTypeNames.Text.Xml)]
public ActionResult<EventSubscriptionResponse> ProcessContentDirectoryEventRequest(string serverId)
{
return ProcessEventRequest(_contentDirectory);
@@ -165,11 +194,15 @@ namespace Jellyfin.Api.Controllers
/// Processes an event subscription request.
/// </summary>
/// <param name="serverId">Server UUID.</param>
/// <response code="200">Request processed.</response>
/// <returns>Event subscription response.</returns>
[HttpSubscribe("{serverId}/ConnectionManager/Events")]
[HttpUnsubscribe("{serverId}/ConnectionManager/Events")]
[ApiExplorerSettings(IgnoreApi = true)] // Ignore in openapi docs
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Text.Xml)]
[ProducesFile(MediaTypeNames.Text.Xml)]
public ActionResult<EventSubscriptionResponse> ProcessConnectionManagerEventRequest(string serverId)
{
return ProcessEventRequest(_connectionManager);
@@ -183,7 +216,9 @@ namespace Jellyfin.Api.Controllers
/// <returns>Icon stream.</returns>
[HttpGet("{serverId}/icons/{fileName}")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "serverId", Justification = "Required for DLNA")]
public ActionResult GetIconId([FromRoute] string serverId, [FromRoute] string fileName)
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesImageFile]
public ActionResult GetIconId([FromRoute, Required] string serverId, [FromRoute, Required] string fileName)
{
return GetIconInternal(fileName);
}
@@ -194,7 +229,8 @@ namespace Jellyfin.Api.Controllers
/// <param name="fileName">The icon filename.</param>
/// <returns>Icon stream.</returns>
[HttpGet("icons/{fileName}")]
public ActionResult GetIcon([FromRoute] string fileName)
[ProducesImageFile]
public ActionResult GetIcon([FromRoute, Required] string fileName)
{
return GetIconInternal(fileName);
}
@@ -221,16 +257,15 @@ namespace Jellyfin.Api.Controllers
private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)
{
return service.ProcessControlRequestAsync(new ControlRequest
return service.ProcessControlRequestAsync(new ControlRequest(Request.Headers)
{
Headers = Request.Headers,
InputXml = requestStream,
TargetServerUuId = id,
RequestedUrl = GetAbsoluteUri()
});
}
private EventSubscriptionResponse ProcessEventRequest(IEventManager eventManager)
private EventSubscriptionResponse ProcessEventRequest(IDlnaEventManager dlnaEventManager)
{
var subscriptionId = Request.Headers["SID"];
if (string.Equals(Request.Method, "subscribe", StringComparison.OrdinalIgnoreCase))
@@ -241,17 +276,17 @@ namespace Jellyfin.Api.Controllers
if (string.IsNullOrEmpty(notificationType))
{
return eventManager.RenewEventSubscription(
return dlnaEventManager.RenewEventSubscription(
subscriptionId,
notificationType,
timeoutString,
callback);
}
return eventManager.CreateEventSubscription(notificationType, timeoutString, callback);
return dlnaEventManager.CreateEventSubscription(notificationType, timeoutString, callback);
}
return eventManager.CancelEventSubscription(subscriptionId);
return dlnaEventManager.CancelEventSubscription(subscriptionId);
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
@@ -8,12 +8,12 @@ using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.PlaybackDtos;
using Jellyfin.Api.Models.StreamingDtos;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Dlna;
@@ -22,7 +22,6 @@ using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Net;
using Microsoft.AspNetCore.Authorization;
@@ -53,9 +52,9 @@ namespace Jellyfin.Api.Controllers
private readonly IConfiguration _configuration;
private readonly IDeviceManager _deviceManager;
private readonly TranscodingJobHelper _transcodingJobHelper;
private readonly INetworkManager _networkManager;
private readonly ILogger<DynamicHlsController> _logger;
private readonly EncodingHelper _encodingHelper;
private readonly DynamicHlsHelper _dynamicHlsHelper;
private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
@@ -74,8 +73,8 @@ namespace Jellyfin.Api.Controllers
/// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param>
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
/// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param>
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{DynamicHlsController}"/> interface.</param>
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
public DynamicHlsController(
ILibraryManager libraryManager,
IUserManager userManager,
@@ -89,8 +88,8 @@ namespace Jellyfin.Api.Controllers
IConfiguration configuration,
IDeviceManager deviceManager,
TranscodingJobHelper transcodingJobHelper,
INetworkManager networkManager,
ILogger<DynamicHlsController> logger)
ILogger<DynamicHlsController> logger,
DynamicHlsHelper dynamicHlsHelper)
{
_libraryManager = libraryManager;
_userManager = userManager;
@@ -104,8 +103,8 @@ namespace Jellyfin.Api.Controllers
_configuration = configuration;
_deviceManager = deviceManager;
_transcodingJobHelper = transcodingJobHelper;
_networkManager = networkManager;
_logger = logger;
_dynamicHlsHelper = dynamicHlsHelper;
_encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
}
@@ -114,7 +113,6 @@ namespace Jellyfin.Api.Controllers
/// Gets a video hls playlist stream.
/// </summary>
/// <param name="itemId">The item id.</param>
/// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
/// <param name="params">The streaming parameters.</param>
/// <param name="tag">The tag.</param>
@@ -168,9 +166,9 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Videos/{itemId}/master.m3u8")]
[HttpHead("Videos/{itemId}/master.m3u8", Name = "HeadMasterHlsVideoPlaylist")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesPlaylistFile]
public async Task<ActionResult> GetMasterHlsVideoPlaylist(
[FromRoute] Guid itemId,
[FromRoute] string? container,
[FromRoute, Required] Guid itemId,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
@@ -179,7 +177,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery, Required] string? mediaSourceId,
[FromQuery, Required] string mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
@@ -220,12 +218,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] Dictionary<string, string> streamOptions,
[FromQuery] bool enableAdaptiveBitrateStreaming = true)
{
var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
var cancellationTokenSource = new CancellationTokenSource();
var streamingRequest = new HlsVideoRequestDto
{
Id = itemId,
Container = container,
Static = @static ?? true,
Params = @params,
Tag = tag,
@@ -276,15 +271,13 @@ namespace Jellyfin.Api.Controllers
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
};
return await GetMasterPlaylistInternal(streamingRequest, isHeadRequest, enableAdaptiveBitrateStreaming, cancellationTokenSource)
.ConfigureAwait(false);
return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
}
/// <summary>
/// Gets an audio hls playlist stream.
/// </summary>
/// <param name="itemId">The item id.</param>
/// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
/// <param name="params">The streaming parameters.</param>
/// <param name="tag">The tag.</param>
@@ -302,6 +295,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
@@ -338,9 +332,9 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Audio/{itemId}/master.m3u8")]
[HttpHead("Audio/{itemId}/master.m3u8", Name = "HeadMasterHlsAudioPlaylist")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesPlaylistFile]
public async Task<ActionResult> GetMasterHlsAudioPlaylist(
[FromRoute] Guid itemId,
[FromRoute] string? container,
[FromRoute, Required] Guid itemId,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
@@ -349,7 +343,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery, Required] string? mediaSourceId,
[FromQuery, Required] string mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
@@ -358,6 +352,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? maxStreamingBitrate,
[FromQuery] int? audioBitRate,
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
@@ -390,12 +385,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] Dictionary<string, string> streamOptions,
[FromQuery] bool enableAdaptiveBitrateStreaming = true)
{
var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
var cancellationTokenSource = new CancellationTokenSource();
var streamingRequest = new HlsAudioRequestDto
{
Id = itemId,
Container = container,
Static = @static ?? true,
Params = @params,
Tag = tag,
@@ -413,7 +405,7 @@ namespace Jellyfin.Api.Controllers
BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate,
AudioBitRate = audioBitRate ?? maxStreamingBitrate,
MaxAudioBitDepth = maxAudioBitDepth,
AudioChannels = audioChannels,
Profile = profile,
@@ -446,15 +438,13 @@ namespace Jellyfin.Api.Controllers
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
};
return await GetMasterPlaylistInternal(streamingRequest, isHeadRequest, enableAdaptiveBitrateStreaming, cancellationTokenSource)
.ConfigureAwait(false);
return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
}
/// <summary>
/// Gets a video stream using HTTP live streaming.
/// </summary>
/// <param name="itemId">The item id.</param>
/// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
/// <param name="params">The streaming parameters.</param>
/// <param name="tag">The tag.</param>
@@ -506,9 +496,9 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("Videos/{itemId}/main.m3u8")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesPlaylistFile]
public async Task<ActionResult> GetVariantHlsVideoPlaylist(
[FromRoute] Guid itemId,
[FromRoute] string? container,
[FromRoute, Required] Guid itemId,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
@@ -561,7 +551,6 @@ namespace Jellyfin.Api.Controllers
var streamingRequest = new VideoRequestDto
{
Id = itemId,
Container = container,
Static = @static ?? true,
Params = @params,
Tag = tag,
@@ -619,7 +608,6 @@ namespace Jellyfin.Api.Controllers
/// Gets an audio stream using HTTP live streaming.
/// </summary>
/// <param name="itemId">The item id.</param>
/// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
/// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
/// <param name="params">The streaming parameters.</param>
/// <param name="tag">The tag.</param>
@@ -637,6 +625,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
@@ -671,9 +660,9 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("Audio/{itemId}/main.m3u8")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesPlaylistFile]
public async Task<ActionResult> GetVariantHlsAudioPlaylist(
[FromRoute] Guid itemId,
[FromRoute] string? container,
[FromRoute, Required] Guid itemId,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
@@ -691,6 +680,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? maxStreamingBitrate,
[FromQuery] int? audioBitRate,
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
@@ -726,7 +716,6 @@ namespace Jellyfin.Api.Controllers
var streamingRequest = new StreamingRequestDto
{
Id = itemId,
Container = container,
Static = @static ?? true,
Params = @params,
Tag = tag,
@@ -744,7 +733,7 @@ namespace Jellyfin.Api.Controllers
BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate,
AudioBitRate = audioBitRate ?? maxStreamingBitrate,
MaxAudioBitDepth = maxAudioBitDepth,
AudioChannels = audioChannels,
Profile = profile,
@@ -838,11 +827,12 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesVideoFile]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> GetHlsVideoSegment(
[FromRoute] Guid itemId,
[FromRoute] string playlistId,
[FromRoute] int segmentId,
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId,
[FromRoute] string container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
@@ -973,6 +963,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
/// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
/// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
@@ -1007,11 +998,12 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesAudioFile]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "playlistId", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> GetHlsAudioSegment(
[FromRoute] Guid itemId,
[FromRoute] string playlistId,
[FromRoute] int segmentId,
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId,
[FromRoute] string container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
@@ -1030,6 +1022,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? maxStreamingBitrate,
[FromQuery] int? audioBitRate,
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
@@ -1082,7 +1075,7 @@ namespace Jellyfin.Api.Controllers
BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate,
AudioBitRate = audioBitRate ?? maxStreamingBitrate,
MaxAudioBitDepth = maxAudioBitDepth,
AudioChannels = audioChannels,
Profile = profile,
@@ -1118,106 +1111,6 @@ namespace Jellyfin.Api.Controllers
.ConfigureAwait(false);
}
private async Task<ActionResult> GetMasterPlaylistInternal(
StreamingRequestDto streamingRequest,
bool isHeadRequest,
bool enableAdaptiveBitrateStreaming,
CancellationTokenSource cancellationTokenSource)
{
using var state = await StreamingHelpers.GetStreamingState(
streamingRequest,
Request,
_authContext,
_mediaSourceManager,
_userManager,
_libraryManager,
_serverConfigurationManager,
_mediaEncoder,
_fileSystem,
_subtitleEncoder,
_configuration,
_dlnaManager,
_deviceManager,
_transcodingJobHelper,
_transcodingJobType,
cancellationTokenSource.Token)
.ConfigureAwait(false);
Response.Headers.Add(HeaderNames.Expires, "0");
if (isHeadRequest)
{
return new FileContentResult(Array.Empty<byte>(), MimeTypes.GetMimeType("playlist.m3u8"));
}
var totalBitrate = state.OutputAudioBitrate ?? 0 + state.OutputVideoBitrate ?? 0;
var builder = new StringBuilder();
builder.AppendLine("#EXTM3U");
var isLiveStream = state.IsSegmentedLiveStream;
var queryString = Request.QueryString.ToString();
// from universal audio service
if (queryString.IndexOf("SegmentContainer", StringComparison.OrdinalIgnoreCase) == -1 && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer))
{
queryString += "&SegmentContainer=" + state.Request.SegmentContainer;
}
// from universal audio service
if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) && queryString.IndexOf("TranscodeReasons=", StringComparison.OrdinalIgnoreCase) == -1)
{
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
}
// Main stream
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
playlistUrl += queryString;
var subtitleStreams = state.MediaSource
.MediaStreams
.Where(i => i.IsTextSubtitleStream)
.ToList();
var subtitleGroup = subtitleStreams.Count > 0 && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest!.EnableSubtitlesInManifest)
? "subs"
: null;
// If we're burning in subtitles then don't add additional subs to the manifest
if (state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
{
subtitleGroup = null;
}
if (!string.IsNullOrWhiteSpace(subtitleGroup))
{
AddSubtitles(state, subtitleStreams, builder);
}
AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming))
{
var requestedVideoBitrate = state.VideoRequest == null ? 0 : state.VideoRequest.VideoBitRate ?? 0;
// By default, vary by just 200k
var variation = GetBitrateVariation(totalBitrate);
var newBitrate = totalBitrate - variation;
var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
variation *= 2;
newBitrate = totalBitrate - variation;
variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
}
return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
}
private async Task<ActionResult> GetVariantPlaylistInternal(StreamingRequestDto streamingRequest, string name, CancellationTokenSource cancellationTokenSource)
{
using var state = await StreamingHelpers.GetStreamingState(
@@ -1245,30 +1138,30 @@ namespace Jellyfin.Api.Controllers
var builder = new StringBuilder();
builder.AppendLine("#EXTM3U");
builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
builder.AppendLine("#EXT-X-VERSION:3");
builder.AppendLine("#EXT-X-TARGETDURATION:" + Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength).ToString(CultureInfo.InvariantCulture));
builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0");
builder.AppendLine("#EXTM3U")
.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
.AppendLine("#EXT-X-VERSION:3")
.Append("#EXT-X-TARGETDURATION:")
.Append(Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength))
.AppendLine()
.AppendLine("#EXT-X-MEDIA-SEQUENCE:0");
var queryString = Request.QueryString;
var index = 0;
var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer);
var queryString = Request.QueryString;
foreach (var length in segmentLengths)
{
builder.AppendLine("#EXTINF:" + length.ToString("0.0000", CultureInfo.InvariantCulture) + ", nodesc");
builder.AppendLine(
string.Format(
CultureInfo.InvariantCulture,
"hls1/{0}/{1}{2}{3}",
name,
index.ToString(CultureInfo.InvariantCulture),
segmentExtension,
queryString));
index++;
builder.Append("#EXTINF:")
.Append(length.ToString("0.0000", CultureInfo.InvariantCulture))
.AppendLine(", nodesc")
.Append("hls1/")
.Append(name)
.Append('/')
.Append(index++)
.Append(segmentExtension)
.Append(queryString)
.AppendLine();
}
builder.AppendLine("#EXT-X-ENDLIST");
@@ -1411,330 +1304,6 @@ namespace Jellyfin.Api.Controllers
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
}
private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder)
{
var selectedIndex = state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index;
const string Format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\"";
foreach (var stream in subtitles)
{
var name = stream.DisplayTitle;
var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index;
var isForced = stream.IsForced;
var url = string.Format(
CultureInfo.InvariantCulture,
"{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}",
state.Request.MediaSourceId,
stream.Index.ToString(CultureInfo.InvariantCulture),
30.ToString(CultureInfo.InvariantCulture),
ClaimHelpers.GetToken(Request.HttpContext.User));
var line = string.Format(
CultureInfo.InvariantCulture,
Format,
name,
isDefault ? "YES" : "NO",
isForced ? "YES" : "NO",
url,
stream.Language ?? "Unknown");
builder.AppendLine(line);
}
}
private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
{
builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
.Append(bitrate.ToString(CultureInfo.InvariantCulture))
.Append(",AVERAGE-BANDWIDTH=")
.Append(bitrate.ToString(CultureInfo.InvariantCulture));
AppendPlaylistCodecsField(builder, state);
AppendPlaylistResolutionField(builder, state);
AppendPlaylistFramerateField(builder, state);
if (!string.IsNullOrWhiteSpace(subtitleGroup))
{
builder.Append(",SUBTITLES=\"")
.Append(subtitleGroup)
.Append('"');
}
builder.Append(Environment.NewLine);
builder.AppendLine(url);
}
/// <summary>
/// Appends a CODECS field containing formatted strings of
/// the active streams output video and audio codecs.
/// </summary>
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
/// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
/// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
/// <param name="builder">StringBuilder to append the field to.</param>
/// <param name="state">StreamState of the current stream.</param>
private void AppendPlaylistCodecsField(StringBuilder builder, StreamState state)
{
// Video
string videoCodecs = string.Empty;
int? videoCodecLevel = GetOutputVideoCodecLevel(state);
if (!string.IsNullOrEmpty(state.ActualOutputVideoCodec) && videoCodecLevel.HasValue)
{
videoCodecs = GetPlaylistVideoCodecs(state, state.ActualOutputVideoCodec, videoCodecLevel.Value);
}
// Audio
string audioCodecs = string.Empty;
if (!string.IsNullOrEmpty(state.ActualOutputAudioCodec))
{
audioCodecs = GetPlaylistAudioCodecs(state);
}
StringBuilder codecs = new StringBuilder();
codecs.Append(videoCodecs);
if (!string.IsNullOrEmpty(videoCodecs) && !string.IsNullOrEmpty(audioCodecs))
{
codecs.Append(',');
}
codecs.Append(audioCodecs);
if (codecs.Length > 1)
{
builder.Append(",CODECS=\"")
.Append(codecs)
.Append('"');
}
}
/// <summary>
/// Appends a RESOLUTION field containing the resolution of the output stream.
/// </summary>
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
/// <param name="builder">StringBuilder to append the field to.</param>
/// <param name="state">StreamState of the current stream.</param>
private void AppendPlaylistResolutionField(StringBuilder builder, StreamState state)
{
if (state.OutputWidth.HasValue && state.OutputHeight.HasValue)
{
builder.Append(",RESOLUTION=")
.Append(state.OutputWidth.GetValueOrDefault())
.Append('x')
.Append(state.OutputHeight.GetValueOrDefault());
}
}
/// <summary>
/// Appends a FRAME-RATE field containing the framerate of the output stream.
/// </summary>
/// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
/// <param name="builder">StringBuilder to append the field to.</param>
/// <param name="state">StreamState of the current stream.</param>
private void AppendPlaylistFramerateField(StringBuilder builder, StreamState state)
{
double? framerate = null;
if (state.TargetFramerate.HasValue)
{
framerate = Math.Round(state.TargetFramerate.GetValueOrDefault(), 3);
}
else if (state.VideoStream?.RealFrameRate != null)
{
framerate = Math.Round(state.VideoStream.RealFrameRate.GetValueOrDefault(), 3);
}
if (framerate.HasValue)
{
builder.Append(",FRAME-RATE=")
.Append(framerate.Value);
}
}
private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream, bool enableAdaptiveBitrateStreaming)
{
// Within the local network this will likely do more harm than good.
var ip = RequestHelpers.NormalizeIp(Request.HttpContext.Connection.RemoteIpAddress).ToString();
if (_networkManager.IsInLocalNetwork(ip))
{
return false;
}
if (!enableAdaptiveBitrateStreaming)
{
return false;
}
if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath))
{
// Opening live streams is so slow it's not even worth it
return false;
}
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
{
return false;
}
if (EncodingHelper.IsCopyCodec(state.OutputAudioCodec))
{
return false;
}
if (!state.IsOutputVideo)
{
return false;
}
// Having problems in android
return false;
// return state.VideoRequest.VideoBitRate.HasValue;
}
/// <summary>
/// Get the H.26X level of the output video stream.
/// </summary>
/// <param name="state">StreamState of the current stream.</param>
/// <returns>H.26X level of the output video stream.</returns>
private int? GetOutputVideoCodecLevel(StreamState state)
{
string? levelString;
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.Level.HasValue)
{
levelString = state.VideoStream?.Level.ToString();
}
else
{
levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec);
}
if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
{
return parsedLevel;
}
return null;
}
/// <summary>
/// Gets a formatted string of the output audio codec, for use in the CODECS field.
/// </summary>
/// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
/// <seealso cref="GetPlaylistVideoCodecs(StreamState, string, int)"/>
/// <param name="state">StreamState of the current stream.</param>
/// <returns>Formatted audio codec string.</returns>
private string GetPlaylistAudioCodecs(StreamState state)
{
if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
{
string? profile = state.GetRequestedProfiles("aac").FirstOrDefault();
return HlsCodecStringHelpers.GetAACString(profile);
}
if (string.Equals(state.ActualOutputAudioCodec, "mp3", StringComparison.OrdinalIgnoreCase))
{
return HlsCodecStringHelpers.GetMP3String();
}
if (string.Equals(state.ActualOutputAudioCodec, "ac3", StringComparison.OrdinalIgnoreCase))
{
return HlsCodecStringHelpers.GetAC3String();
}
if (string.Equals(state.ActualOutputAudioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
{
return HlsCodecStringHelpers.GetEAC3String();
}
return string.Empty;
}
/// <summary>
/// Gets a formatted string of the output video codec, for use in the CODECS field.
/// </summary>
/// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
/// <seealso cref="GetPlaylistAudioCodecs(StreamState)"/>
/// <param name="state">StreamState of the current stream.</param>
/// <param name="codec">Video codec.</param>
/// <param name="level">Video level.</param>
/// <returns>Formatted video codec string.</returns>
private string GetPlaylistVideoCodecs(StreamState state, string codec, int level)
{
if (level == 0)
{
// This is 0 when there's no requested H.26X level in the device profile
// and the source is not encoded in H.26X
_logger.LogError("Got invalid H.26X level when building CODECS field for HLS master playlist");
return string.Empty;
}
if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
{
string profile = state.GetRequestedProfiles("h264").FirstOrDefault();
return HlsCodecStringHelpers.GetH264String(profile, level);
}
if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
{
string profile = state.GetRequestedProfiles("h265").FirstOrDefault();
return HlsCodecStringHelpers.GetH265String(profile, level);
}
return string.Empty;
}
private int GetBitrateVariation(int bitrate)
{
// By default, vary by just 50k
var variation = 50000;
if (bitrate >= 10000000)
{
variation = 2000000;
}
else if (bitrate >= 5000000)
{
variation = 1500000;
}
else if (bitrate >= 3000000)
{
variation = 1000000;
}
else if (bitrate >= 2000000)
{
variation = 500000;
}
else if (bitrate >= 1000000)
{
variation = 300000;
}
else if (bitrate >= 600000)
{
variation = 200000;
}
else if (bitrate >= 400000)
{
variation = 100000;
}
return variation;
}
private string ReplaceBitrate(string url, int oldValue, int newValue)
{
return url.Replace(
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
StringComparison.OrdinalIgnoreCase);
}
private double[] GetSegmentLengths(StreamState state)
{
var result = new List<double>();
@@ -1786,15 +1355,20 @@ namespace Jellyfin.Api.Controllers
segmentFormat = "mpegts";
}
var maxMuxingQueueSize = encodingOptions.MaxMuxingQueueSize > 128
? encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
: "128";
return string.Format(
CultureInfo.InvariantCulture,
"{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -f hls -max_delay 5000000 -hls_time {6} -individual_header_trailer 0 -hls_segment_type {7} -start_number {8} -hls_segment_filename \"{9}\" -hls_playlist_type vod -hls_list_size 0 -y \"{10}\"",
"{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -individual_header_trailer 0 -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"",
inputModifier,
_encodingHelper.GetInputArgument(state, encodingOptions),
threads,
mapArgs,
GetVideoArguments(state, encodingOptions, startNumber),
GetAudioArguments(state, encodingOptions),
maxMuxingQueueSize,
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
segmentFormat,
startNumberParam,
@@ -1885,7 +1459,7 @@ namespace Jellyfin.Api.Controllers
var args = "-codec:v:0 " + codec;
// if (state.EnableMpegtsM2TsMode)
// if (state.EnableMpegtsM2TsMode)
// {
// args += " -mpegts_m2ts_mode 1";
// }
@@ -2089,7 +1663,7 @@ namespace Jellyfin.Api.Controllers
return Task.CompletedTask;
});
return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)!, false, this);
return FileStreamResponseHelpers.GetStaticFileResult(segmentPath, MimeTypes.GetMimeType(segmentPath)!, false, HttpContext);
}
private long GetEndPositionTicks(StreamState state, int requestedIndex)

View File

@@ -69,11 +69,11 @@ namespace Jellyfin.Api.Controllers
/// Validates path.
/// </summary>
/// <param name="validatePathDto">Validate request object.</param>
/// <response code="200">Path validated.</response>
/// <response code="204">Path validated.</response>
/// <response code="404">Path not found.</response>
/// <returns>Validation status.</returns>
[HttpPost("ValidatePath")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult ValidatePath([FromBody, Required] ValidatePathDto validatePathDto)
{
@@ -118,7 +118,7 @@ namespace Jellyfin.Api.Controllers
}
}
return Ok();
return NoContent();
}
/// <summary>

View File

@@ -1,5 +1,5 @@
using System;
using System.Globalization;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
@@ -9,6 +9,7 @@ using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -46,7 +47,6 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Gets all genres from a given item, folder, or the entire library.
/// </summary>
/// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="searchTerm">The search term.</param>
@@ -54,22 +54,9 @@ namespace Jellyfin.Api.Controllers
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
/// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
/// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
/// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
/// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
/// <param name="enableUserData">Optional, include user data.</param>
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
/// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
/// <param name="userId">User id.</param>
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -81,7 +68,6 @@ namespace Jellyfin.Api.Controllers
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetGenres(
[FromQuery] double? minCommunityRating,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
@@ -89,22 +75,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? fields,
[FromQuery] string? excludeItemTypes,
[FromQuery] string? includeItemTypes,
[FromQuery] string? filters,
[FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes,
[FromQuery] string? genres,
[FromQuery] string? genreIds,
[FromQuery] string? officialRatings,
[FromQuery] string? tags,
[FromQuery] string? years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] string? person,
[FromQuery] string? personIds,
[FromQuery] string? personTypes,
[FromQuery] string? studios,
[FromQuery] string? studioIds,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] Guid? userId,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
@@ -115,42 +88,22 @@ namespace Jellyfin.Api.Controllers
var dtoOptions = new DtoOptions()
.AddItemFields(fields)
.AddClientFields(Request)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
User? user = null;
BaseItem parentItem;
User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null;
if (userId.HasValue && !userId.Equals(Guid.Empty))
{
user = _userManager.GetUserById(userId.Value);
parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
}
else
{
parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
}
var parentItem = _libraryManager.GetParentItem(parentId, userId);
var query = new InternalItemsQuery(user)
{
ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
Tags = RequestHelpers.Split(tags, '|', true),
OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
Genres = RequestHelpers.Split(genres, '|', true),
GenreIds = RequestHelpers.GetGuids(genreIds),
StudioIds = RequestHelpers.GetGuids(studioIds),
Person = person,
PersonIds = RequestHelpers.GetGuids(personIds),
PersonTypes = RequestHelpers.Split(personTypes, ',', true),
Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
MinCommunityRating = minCommunityRating,
DtoOptions = dtoOptions,
SearchTerm = searchTerm,
EnableTotalRecordCount = enableTotalRecordCount
@@ -168,87 +121,20 @@ namespace Jellyfin.Api.Controllers
}
}
// Studios
if (!string.IsNullOrEmpty(studios))
QueryResult<(BaseItem, ItemCounts)> result;
if (parentItem is ICollectionFolder parentCollectionFolder
&& (string.Equals(parentCollectionFolder.CollectionType, CollectionType.Music, StringComparison.Ordinal)
|| string.Equals(parentCollectionFolder.CollectionType, CollectionType.MusicVideos, StringComparison.Ordinal)))
{
query.StudioIds = studios.Split('|')
.Select(i =>
{
try
{
return _libraryManager.GetStudio(i);
}
catch
{
return null;
}
}).Where(i => i != null)
.Select(i => i!.Id)
.ToArray();
result = _libraryManager.GetMusicGenres(query);
}
else
{
result = _libraryManager.GetGenres(query);
}
foreach (var filter in RequestHelpers.GetFilters(filters))
{
switch (filter)
{
case ItemFilter.Dislikes:
query.IsLiked = false;
break;
case ItemFilter.IsFavorite:
query.IsFavorite = true;
break;
case ItemFilter.IsFavoriteOrLikes:
query.IsFavoriteOrLiked = true;
break;
case ItemFilter.IsFolder:
query.IsFolder = true;
break;
case ItemFilter.IsNotFolder:
query.IsFolder = false;
break;
case ItemFilter.IsPlayed:
query.IsPlayed = true;
break;
case ItemFilter.IsResumable:
query.IsResumable = true;
break;
case ItemFilter.IsUnplayed:
query.IsPlayed = false;
break;
case ItemFilter.Likes:
query.IsLiked = true;
break;
}
}
var result = new QueryResult<(BaseItem, ItemCounts)>();
var dtos = result.Items.Select(i =>
{
var (baseItem, counts) = i;
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
if (!string.IsNullOrWhiteSpace(includeItemTypes))
{
dto.ChildCount = counts.ItemCount;
dto.ProgramCount = counts.ProgramCount;
dto.SeriesCount = counts.SeriesCount;
dto.EpisodeCount = counts.EpisodeCount;
dto.MovieCount = counts.MovieCount;
dto.TrailerCount = counts.TrailerCount;
dto.AlbumCount = counts.AlbumCount;
dto.SongCount = counts.SongCount;
dto.ArtistCount = counts.ArtistCount;
}
return dto;
});
return new QueryResult<BaseItemDto>
{
Items = dtos.ToArray(),
TotalRecordCount = result.TotalRecordCount
};
var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
/// <summary>
@@ -260,7 +146,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the genre.</returns>
[HttpGet("{genreName}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<BaseItemDto> GetGenre([FromRoute] string genreName, [FromQuery] Guid? userId)
public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
{
var dtoOptions = new DtoOptions()
.AddClientFields(Request);

View File

@@ -1,8 +1,10 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Configuration;
@@ -54,14 +56,15 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Audio/{itemId}/hls/{segmentId}/stream.mp3", Name = "GetHlsAudioSegmentLegacyMp3")]
[HttpGet("Audio/{itemId}/hls/{segmentId}/stream.aac", Name = "GetHlsAudioSegmentLegacyAac")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesAudioFile]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
public ActionResult GetHlsAudioSegmentLegacy([FromRoute] string itemId, [FromRoute] string segmentId)
public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
{
// TODO: Deprecate with new iOS app
var file = segmentId + Path.GetExtension(Request.Path);
file = Path.Combine(_serverConfigurationManager.GetTranscodePath(), file);
return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)!, false, this);
return FileStreamResponseHelpers.GetStaticFileResult(file, MimeTypes.GetMimeType(file)!, false, HttpContext);
}
/// <summary>
@@ -74,8 +77,9 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Videos/{itemId}/hls/{playlistId}/stream.m3u8")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesPlaylistFile]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
public ActionResult GetHlsPlaylistLegacy([FromRoute] string itemId, [FromRoute] string playlistId)
public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
{
var file = playlistId + Path.GetExtension(Request.Path);
file = Path.Combine(_serverConfigurationManager.GetTranscodePath(), file);
@@ -112,12 +116,13 @@ namespace Jellyfin.Api.Controllers
// [Authenticated]
[HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesVideoFile]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
public ActionResult GetHlsVideoSegmentLegacy(
[FromRoute] string itemId,
[FromRoute] string playlistId,
[FromRoute] string segmentId,
[FromRoute] string segmentContainer)
[FromRoute, Required] string itemId,
[FromRoute, Required] string playlistId,
[FromRoute, Required] string segmentId,
[FromRoute, Required] string segmentContainer)
{
var file = segmentId + Path.GetExtension(Request.Path);
var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
@@ -148,7 +153,7 @@ namespace Jellyfin.Api.Controllers
return Task.CompletedTask;
});
return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)!, false, this);
return FileStreamResponseHelpers.GetStaticFileResult(path, MimeTypes.GetMimeType(path)!, false, HttpContext);
}
}
}

View File

@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Net.Mime;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
@@ -65,7 +66,8 @@ namespace Jellyfin.Api.Controllers
[Produces(MediaTypeNames.Application.Octet)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<FileStreamResult> GetGeneralImage([FromRoute, Required] string? name, [FromRoute, Required] string? type)
[ProducesImageFile]
public ActionResult GetGeneralImage([FromRoute, Required] string name, [FromRoute, Required] string type)
{
var filename = string.Equals(type, "primary", StringComparison.OrdinalIgnoreCase)
? "folder"
@@ -110,9 +112,10 @@ namespace Jellyfin.Api.Controllers
[Produces(MediaTypeNames.Application.Octet)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<FileStreamResult> GetRatingImage(
[FromRoute, Required] string? theme,
[FromRoute, Required] string? name)
[ProducesImageFile]
public ActionResult GetRatingImage(
[FromRoute, Required] string theme,
[FromRoute, Required] string name)
{
return GetImageFile(_applicationPaths.RatingsPath, theme, name);
}
@@ -143,9 +146,10 @@ namespace Jellyfin.Api.Controllers
[Produces(MediaTypeNames.Application.Octet)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<FileStreamResult> GetMediaInfoImage(
[FromRoute, Required] string? theme,
[FromRoute, Required] string? name)
[ProducesImageFile]
public ActionResult GetMediaInfoImage(
[FromRoute, Required] string theme,
[FromRoute, Required] string name)
{
return GetImageFile(_applicationPaths.MediaInfoImagesPath, theme, name);
}
@@ -157,7 +161,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="theme">Theme to search.</param>
/// <param name="name">File name to search for.</param>
/// <returns>A <see cref="FileStreamResult"/> containing the image contents on success, or a <see cref="NotFoundResult"/> if the image could not be found.</returns>
private ActionResult<FileStreamResult> GetImageFile(string basePath, string? theme, string? name)
private ActionResult GetImageFile(string basePath, string? theme, string? name)
{
var themeFolder = Path.Combine(basePath, theme);
if (Directory.Exists(themeFolder))
@@ -168,7 +172,7 @@ namespace Jellyfin.Api.Controllers
if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
{
var contentType = MimeTypes.GetMimeType(path);
return File(System.IO.File.OpenRead(path), contentType);
return PhysicalFile(path, contentType);
}
}
@@ -181,7 +185,7 @@ namespace Jellyfin.Api.Controllers
if (!string.IsNullOrEmpty(path) && System.IO.File.Exists(path))
{
var contentType = MimeTypes.GetMimeType(path);
return File(System.IO.File.OpenRead(path), contentType);
return PhysicalFile(path, contentType);
}
}

View File

@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using MediaBrowser.Controller.Configuration;
@@ -90,8 +92,8 @@ namespace Jellyfin.Api.Controllers
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> PostUserImage(
[FromRoute] Guid userId,
[FromRoute] ImageType imageType,
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
[FromRoute] int? index = null)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
@@ -107,13 +109,13 @@ namespace Jellyfin.Api.Controllers
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
if (user.ProfileImage != null)
{
_userManager.ClearProfileImage(user);
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
}
user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType)));
await _providerManager
.SaveImage(user, memoryStream, mimeType, user.ProfileImage.Path)
.SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
.ConfigureAwait(false);
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
@@ -136,9 +138,9 @@ namespace Jellyfin.Api.Controllers
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult DeleteUserImage(
[FromRoute] Guid userId,
[FromRoute] ImageType imageType,
public async Task<ActionResult> DeleteUserImage(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
[FromRoute] int? index = null)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
@@ -156,7 +158,7 @@ namespace Jellyfin.Api.Controllers
_logger.LogError(e, "Error deleting user profile image:");
}
_userManager.ClearProfileImage(user);
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
return NoContent();
}
@@ -174,9 +176,9 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DeleteItemImage(
[FromRoute] Guid itemId,
[FromRoute] ImageType imageType,
public async Task<ActionResult> DeleteItemImage(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
[FromRoute] int? imageIndex = null)
{
var item = _libraryManager.GetItemById(itemId);
@@ -185,7 +187,7 @@ namespace Jellyfin.Api.Controllers
return NotFound();
}
item.DeleteImage(imageType, imageIndex ?? 0);
await item.DeleteImageAsync(imageType, imageIndex ?? 0).ConfigureAwait(false);
return NoContent();
}
@@ -205,8 +207,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> SetItemImage(
[FromRoute] Guid itemId,
[FromRoute] ImageType imageType,
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
[FromRoute] int? imageIndex = null)
{
var item = _libraryManager.GetItemById(itemId);
@@ -218,7 +220,7 @@ namespace Jellyfin.Api.Controllers
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType.Split(';').FirstOrDefault();
await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None);
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
return NoContent();
}
@@ -237,10 +239,10 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateItemImageIndex(
[FromRoute] Guid itemId,
[FromRoute] ImageType imageType,
[FromRoute] int imageIndex,
public async Task<ActionResult> UpdateItemImageIndex(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
[FromRoute, Required] int imageIndex,
[FromQuery] int newIndex)
{
var item = _libraryManager.GetItemById(itemId);
@@ -249,7 +251,7 @@ namespace Jellyfin.Api.Controllers
return NotFound();
}
item.SwapImages(imageType, imageIndex, newIndex);
await item.SwapImagesAsync(imageType, imageIndex, newIndex).ConfigureAwait(false);
return NoContent();
}
@@ -264,7 +266,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<IEnumerable<ImageInfo>> GetItemImageInfos([FromRoute] Guid itemId)
public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -281,7 +283,7 @@ namespace Jellyfin.Api.Controllers
return list;
}
_libraryManager.UpdateImages(item); // this makes sure dimensions and hashes are correct
await _libraryManager.UpdateImagesAsync(item).ConfigureAwait(false); // this makes sure dimensions and hashes are correct
foreach (var image in itemImages)
{
@@ -331,7 +333,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
/// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
/// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
/// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
/// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
@@ -351,17 +353,18 @@ namespace Jellyfin.Api.Controllers
[HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetItemImage(
[FromRoute] Guid itemId,
[FromRoute] ImageType imageType,
[FromRoute] int? maxWidth,
[FromRoute] int? maxHeight,
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
[FromQuery] string? tag,
[FromQuery] bool? cropWhitespace,
[FromQuery] string? format,
[FromQuery] ImageFormat? format,
[FromQuery] bool? addPlayedIndicator,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
@@ -429,24 +432,25 @@ namespace Jellyfin.Api.Controllers
[HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}/{tag}/{format}/{maxWidth}/{maxHeight}/{percentPlayed}/{unplayedCount}", Name = "HeadItemImage2")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetItemImage2(
[FromRoute] Guid itemId,
[FromRoute] ImageType imageType,
[FromRoute] int? maxWidth,
[FromRoute] int? maxHeight,
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
[FromRoute, Required] int maxWidth,
[FromRoute, Required] int maxHeight,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
[FromRoute] string tag,
[FromRoute, Required] string tag,
[FromQuery] bool? cropWhitespace,
[FromRoute] string format,
[FromRoute, Required] ImageFormat format,
[FromQuery] bool? addPlayedIndicator,
[FromRoute] double? percentPlayed,
[FromRoute] int? unplayedCount,
[FromRoute, Required] double percentPlayed,
[FromRoute, Required] int unplayedCount,
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
[FromRoute] int? imageIndex = null)
[FromRoute, Required] int imageIndex)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -507,15 +511,16 @@ namespace Jellyfin.Api.Controllers
[HttpHead("Artists/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadArtistImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetArtistImage(
[FromRoute] string name,
[FromRoute] ImageType imageType,
[FromRoute] string tag,
[FromRoute] string format,
[FromRoute] int? maxWidth,
[FromRoute] int? maxHeight,
[FromRoute] double? percentPlayed,
[FromRoute] int? unplayedCount,
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
[FromQuery] string tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
@@ -524,7 +529,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
[FromRoute] int? imageIndex = null)
[FromRoute, Required] int imageIndex)
{
var item = _libraryManager.GetArtist(name);
if (item == null)
@@ -585,15 +590,16 @@ namespace Jellyfin.Api.Controllers
[HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetGenreImage(
[FromRoute] string name,
[FromRoute] ImageType imageType,
[FromRoute] string tag,
[FromRoute] string format,
[FromRoute] int? maxWidth,
[FromRoute] int? maxHeight,
[FromRoute] double? percentPlayed,
[FromRoute] int? unplayedCount,
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
[FromQuery] string tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
@@ -663,15 +669,16 @@ namespace Jellyfin.Api.Controllers
[HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetMusicGenreImage(
[FromRoute] string name,
[FromRoute] ImageType imageType,
[FromRoute] string tag,
[FromRoute] string format,
[FromRoute] int? maxWidth,
[FromRoute] int? maxHeight,
[FromRoute] double? percentPlayed,
[FromRoute] int? unplayedCount,
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
[FromQuery] string tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
@@ -741,15 +748,16 @@ namespace Jellyfin.Api.Controllers
[HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetPersonImage(
[FromRoute] string name,
[FromRoute] ImageType imageType,
[FromRoute] string tag,
[FromRoute] string format,
[FromRoute] int? maxWidth,
[FromRoute] int? maxHeight,
[FromRoute] double? percentPlayed,
[FromRoute] int? unplayedCount,
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
[FromQuery] string tag,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
@@ -819,15 +827,16 @@ namespace Jellyfin.Api.Controllers
[HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetStudioImage(
[FromRoute] string name,
[FromRoute] ImageType imageType,
[FromRoute] string tag,
[FromRoute] string format,
[FromRoute] int? maxWidth,
[FromRoute] int? maxHeight,
[FromRoute] double? percentPlayed,
[FromRoute] int? unplayedCount,
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
[FromRoute, Required] string tag,
[FromRoute, Required] ImageFormat format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
[FromQuery] int? unplayedCount,
[FromQuery] int? width,
[FromQuery] int? height,
[FromQuery] int? quality,
@@ -897,11 +906,12 @@ namespace Jellyfin.Api.Controllers
[HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetUserImage(
[FromRoute] Guid userId,
[FromRoute] ImageType imageType,
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
[FromQuery] string? tag,
[FromQuery] string? format,
[FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
@@ -1028,7 +1038,7 @@ namespace Jellyfin.Api.Controllers
ImageType imageType,
int? imageIndex,
string? tag,
string? format,
ImageFormat? format,
int? maxWidth,
int? maxHeight,
double? percentPlayed,
@@ -1118,12 +1128,11 @@ namespace Jellyfin.Api.Controllers
isHeadRequest).ConfigureAwait(false);
}
private ImageFormat[] GetOutputFormats(string? format)
private ImageFormat[] GetOutputFormats(ImageFormat? format)
{
if (!string.IsNullOrWhiteSpace(format)
&& Enum.TryParse(format, true, out ImageFormat parsedFormat))
if (format.HasValue)
{
return new[] { parsedFormat };
return new[] { format.Value };
}
return GetClientSupportedFormats();
@@ -1147,7 +1156,7 @@ namespace Jellyfin.Api.Controllers
var acceptParam = Request.Query[HeaderNames.Accept];
var supportsWebP = SupportsFormat(supportedFormats, acceptParam, "webp", false);
var supportsWebP = SupportsFormat(supportedFormats, acceptParam, ImageFormat.Webp, false);
if (!supportsWebP)
{
@@ -1169,7 +1178,7 @@ namespace Jellyfin.Api.Controllers
formats.Add(ImageFormat.Jpg);
formats.Add(ImageFormat.Png);
if (SupportsFormat(supportedFormats, acceptParam, "gif", true))
if (SupportsFormat(supportedFormats, acceptParam, ImageFormat.Gif, true))
{
formats.Add(ImageFormat.Gif);
}
@@ -1177,9 +1186,10 @@ namespace Jellyfin.Api.Controllers
return formats.ToArray();
}
private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string acceptParam, string format, bool acceptAll)
private bool SupportsFormat(IReadOnlyCollection<string> requestAcceptTypes, string acceptParam, ImageFormat format, bool acceptAll)
{
var mimeType = "image/" + format;
var normalized = format.ToString().ToLowerInvariant();
var mimeType = "image/" + normalized;
if (requestAcceptTypes.Contains(mimeType))
{
@@ -1191,7 +1201,7 @@ namespace Jellyfin.Api.Controllers
return true;
}
return string.Equals(acceptParam, format, StringComparison.OrdinalIgnoreCase);
return string.Equals(acceptParam, normalized, StringComparison.OrdinalIgnoreCase);
}
private async Task<ActionResult> GetImageResult(
@@ -1281,9 +1291,9 @@ namespace Jellyfin.Api.Controllers
Response.Headers.Add(HeaderNames.LastModified, dateImageModified.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss \"GMT\"", new CultureInfo("en-US", false)));
// if the image was not modified since "ifModifiedSinceHeader"-header, return a HTTP status code 304 not modified
if (!(dateImageModified > ifModifiedSinceHeader))
if (!(dateImageModified > ifModifiedSinceHeader) && cacheDuration.HasValue)
{
if (ifModifiedSinceHeader.Add(cacheDuration!.Value) < DateTime.UtcNow)
if (ifModifiedSinceHeader.Add(cacheDuration.Value) < DateTime.UtcNow)
{
Response.StatusCode = StatusCodes.Status304NotModified;
return new ContentResult();
@@ -1297,8 +1307,7 @@ namespace Jellyfin.Api.Controllers
return NoContent();
}
var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read);
return File(stream, imageContentType);
return PhysicalFile(imagePath, imageContentType);
}
}
}

View File

@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -64,14 +65,14 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Songs/{id}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong(
[FromRoute] Guid id,
[FromRoute, Required] Guid id,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery] string? fields,
[FromQuery] bool? enableImages,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes)
[FromQuery] ImageType[] enableImageTypes)
{
var item = _libraryManager.GetItemById(id);
var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -101,14 +102,14 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Albums/{id}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum(
[FromRoute] Guid id,
[FromRoute, Required] Guid id,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery] string? fields,
[FromQuery] bool? enableImages,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes)
[FromQuery] ImageType[] enableImageTypes)
{
var album = _libraryManager.GetItemById(id);
var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -138,14 +139,14 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Playlists/{id}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist(
[FromRoute] Guid id,
[FromRoute, Required] Guid id,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery] string? fields,
[FromQuery] bool? enableImages,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes)
[FromQuery] ImageType[] enableImageTypes)
{
var playlist = (Playlist)_libraryManager.GetItemById(id);
var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -175,14 +176,14 @@ namespace Jellyfin.Api.Controllers
[HttpGet("MusicGenres/{name}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenre(
[FromRoute, Required] string? name,
[FromRoute, Required] string name,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery] string? fields,
[FromQuery] bool? enableImages,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes)
[FromQuery] ImageType[] enableImageTypes)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
@@ -211,14 +212,14 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Artists/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
[FromRoute] Guid id,
[FromRoute, Required] Guid id,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery] string? fields,
[FromQuery] bool? enableImages,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes)
[FromQuery] ImageType[] enableImageTypes)
{
var item = _libraryManager.GetItemById(id);
var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -248,14 +249,14 @@ namespace Jellyfin.Api.Controllers
[HttpGet("MusicGenres/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres(
[FromRoute] Guid id,
[FromRoute, Required] Guid id,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery] string? fields,
[FromQuery] bool? enableImages,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes)
[FromQuery] ImageType[] enableImageTypes)
{
var item = _libraryManager.GetItemById(id);
var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -285,14 +286,14 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Items/{id}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem(
[FromRoute] Guid id,
[FromRoute, Required] Guid id,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery] string? fields,
[FromQuery] bool? enableImages,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes)
[FromQuery] ImageType[] enableImageTypes)
{
var item = _libraryManager.GetItemById(id);
var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -315,9 +316,9 @@ namespace Jellyfin.Api.Controllers
TotalRecordCount = list.Count
};
if (limit.HasValue)
if (limit.HasValue && limit < list.Count)
{
list = list.Take(limit.Value).ToList();
list = list.GetRange(0, limit.Value);
}
var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);

View File

@@ -7,6 +7,7 @@ using System.Net.Mime;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
@@ -18,6 +19,7 @@ using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Net;
using MediaBrowser.Model.Providers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -72,7 +74,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute] Guid itemId)
public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -248,6 +250,8 @@ namespace Jellyfin.Api.Controllers
/// The task result contains an <see cref="FileStreamResult"/> containing the images file stream.
/// </returns>
[HttpGet("Items/RemoteSearch/Image")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesImageFile]
public async Task<ActionResult> GetRemoteSearchImage(
[FromQuery, Required] string imageUrl,
[FromQuery, Required] string providerName)
@@ -260,8 +264,7 @@ namespace Jellyfin.Api.Controllers
var contentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false);
if (System.IO.File.Exists(contentPath))
{
await using var fileStreamExisting = System.IO.File.OpenRead(pointerCachePath);
return new FileStreamResult(fileStreamExisting, MediaTypeNames.Application.Octet);
return PhysicalFile(contentPath, MimeTypes.GetMimeType(contentPath));
}
}
catch (FileNotFoundException)
@@ -274,10 +277,8 @@ namespace Jellyfin.Api.Controllers
}
await DownloadImage(providerName, imageUrl, urlHash, pointerCachePath).ConfigureAwait(false);
// Read the pointer file again
await using var fileStream = System.IO.File.OpenRead(pointerCachePath);
return new FileStreamResult(fileStream, MediaTypeNames.Application.Octet);
var updatedContentPath = await System.IO.File.ReadAllTextAsync(pointerCachePath).ConfigureAwait(false);
return PhysicalFile(updatedContentPath, MimeTypes.GetMimeType(updatedContentPath));
}
/// <summary>
@@ -291,10 +292,11 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
/// The task result contains an <see cref="NoContentResult"/>.
/// </returns>
[HttpPost("Items/RemoteSearch/Apply/{id}")]
[HttpPost("Items/RemoteSearch/Apply/{itemId}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> ApplySearchCriteria(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromBody, Required] RemoteSearchResult searchResult,
[FromQuery] bool replaceAllImages = true)
{

View File

@@ -1,5 +1,6 @@
using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Constants;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
@@ -53,7 +54,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult Post(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromQuery] MetadataRefreshMode metadataRefreshMode = MetadataRefreshMode.None,
[FromQuery] MetadataRefreshMode imageRefreshMode = MetadataRefreshMode.None,
[FromQuery] bool replaceAllMetadata = false,

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
@@ -67,7 +68,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Items/{itemId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateItem([FromRoute] Guid itemId, [FromBody, Required] BaseItemDto request)
public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -101,7 +102,7 @@ namespace Jellyfin.Api.Controllers
item.OnMetadataChanged();
item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
if (isLockedChanged && item.IsFolder)
{
@@ -110,7 +111,7 @@ namespace Jellyfin.Api.Controllers
foreach (var child in folder.GetRecursiveChildren())
{
child.IsLocked = newLockData;
child.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
await child.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
}
@@ -140,7 +141,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Items/{itemId}/MetadataEditor")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute] Guid itemId)
public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId)
{
var item = _libraryManager.GetItemById(itemId);
@@ -194,7 +195,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Items/{itemId}/ContentType")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateItemContentType([FromRoute] Guid itemId, [FromQuery, Required] string? contentType)
public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string contentType)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)

View File

@@ -1,13 +1,15 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Data.Entities;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
@@ -158,7 +160,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? isHd,
[FromQuery] bool? is4K,
[FromQuery] string? locationTypes,
[FromQuery] string? excludeLocationTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired,
[FromQuery] double? minCommunityRating,
@@ -181,10 +183,10 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? fields,
[FromQuery] string? excludeItemTypes,
[FromQuery] string? includeItemTypes,
[FromQuery] string? filters,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes,
[FromQuery] string? imageTypes,
[FromQuery] ImageType[] imageTypes,
[FromQuery] string? sortBy,
[FromQuery] bool? isPlayed,
[FromQuery] string? genres,
@@ -193,7 +195,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] string? person,
[FromQuery] string? personIds,
[FromQuery] string? personTypes,
@@ -266,7 +268,9 @@ namespace Jellyfin.Api.Controllers
bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
// Assume all folders inside an EnabledChannel are enabled
|| user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.Id);
|| user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.Id)
// Assume all items inside an EnabledChannel are enabled
|| user.GetPreference(PreferenceKind.EnabledChannels).Any(i => new Guid(i) == item.ChannelId);
var collectionFolders = _libraryManager.GetCollectionFolders(item);
foreach (var collectionFolder in collectionFolders)
@@ -339,7 +343,7 @@ namespace Jellyfin.Api.Controllers
PersonIds = RequestHelpers.GetGuids(personIds),
PersonTypes = RequestHelpers.Split(personTypes, ',', true),
Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
ImageTypes = RequestHelpers.Split(imageTypes, ',', true).Select(v => Enum.Parse<ImageType>(v, true)).ToArray(),
ImageTypes = imageTypes,
VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(),
AdjacentTo = adjacentTo,
ItemIds = RequestHelpers.GetGuids(ids),
@@ -362,7 +366,7 @@ namespace Jellyfin.Api.Controllers
query.CollapseBoxSetItems = false;
}
foreach (var filter in RequestHelpers.GetFilters(filters!))
foreach (var filter in filters)
{
switch (filter)
{
@@ -403,12 +407,9 @@ namespace Jellyfin.Api.Controllers
}
// ExcludeLocationTypes
if (!string.IsNullOrEmpty(excludeLocationTypes))
if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
{
if (excludeLocationTypes.Split(',').Select(d => (LocationType)Enum.Parse(typeof(LocationType), d, true)).ToArray().Contains(LocationType.Virtual))
{
query.IsVirtualItem = false;
}
query.IsVirtualItem = false;
}
if (!string.IsNullOrEmpty(locationTypes))
@@ -527,7 +528,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Users/{userId}/Items/Resume")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetResumeItems(
[FromRoute] Guid userId,
[FromRoute, Required] Guid userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
@@ -536,7 +537,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? mediaTypes,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] string? excludeItemTypes,
[FromQuery] string? includeItemTypes,
[FromQuery] bool enableTotalRecordCount = true,

View File

@@ -8,6 +8,7 @@ using System.Net;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
@@ -19,6 +20,7 @@ using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Net;
@@ -35,8 +37,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Book = MediaBrowser.Controller.Entities.Book;
using Movie = Jellyfin.Data.Entities.Movie;
using MusicAlbum = Jellyfin.Data.Entities.MusicAlbum;
namespace Jellyfin.Api.Controllers
{
@@ -105,7 +105,8 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult GetFile([FromRoute] Guid itemId)
[ProducesFile("video/*", "audio/*")]
public ActionResult GetFile([FromRoute, Required] Guid itemId)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -113,8 +114,7 @@ namespace Jellyfin.Api.Controllers
return NotFound();
}
using var fileStream = new FileStream(item.Path, FileMode.Open, FileAccess.Read);
return File(fileStream, MimeTypes.GetMimeType(item.Path));
return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path));
}
/// <summary>
@@ -145,7 +145,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<ThemeMediaResult> GetThemeSongs(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
[FromQuery] bool inheritFromParent = false)
{
@@ -211,7 +211,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<ThemeMediaResult> GetThemeVideos(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
[FromQuery] bool inheritFromParent = false)
{
@@ -276,7 +276,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<AllThemeMediaResult> GetThemeMedia(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
[FromQuery] bool inheritFromParent = false)
{
@@ -439,7 +439,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute] Guid itemId, [FromQuery] Guid? userId)
public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
{
var item = _libraryManager.GetItemById(itemId);
@@ -556,7 +556,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Library/Movies/Updated")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostUpdatedMovies([FromRoute] string? tmdbId, [FromRoute] string? imdbId)
public ActionResult PostUpdatedMovies([FromQuery] string? tmdbId, [FromQuery] string? imdbId)
{
var movies = _libraryManager.GetItemList(new InternalItemsQuery
{
@@ -619,7 +619,8 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.Download)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult GetDownload([FromRoute] Guid itemId)
[ProducesFile("video/*", "audio/*")]
public async Task<ActionResult> GetDownload([FromRoute, Required] Guid itemId)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -648,7 +649,7 @@ namespace Jellyfin.Api.Controllers
if (user != null)
{
LogDownload(item, user, auth);
await LogDownloadAsync(item, user, auth).ConfigureAwait(false);
}
var path = item.Path;
@@ -688,7 +689,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromQuery] string? excludeArtistIds,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
@@ -861,17 +862,17 @@ namespace Jellyfin.Api.Controllers
: item;
}
private void LogDownload(BaseItem item, User user, AuthorizationInfo auth)
private async Task LogDownloadAsync(BaseItem item, User user, AuthorizationInfo auth)
{
try
{
_activityManager.Create(new ActivityLog(
await _activityManager.CreateAsync(new ActivityLog(
string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("UserDownloadingItemWithValues"), user.Username, item.Name),
"UserDownloadingContent",
auth.UserId)
{
ShortOverview = string.Format(CultureInfo.InvariantCulture, _localization.GetLocalizedString("AppDeviceValues"), auth.Client, auth.Device),
});
}).ConfigureAwait(false);
}
catch
{

View File

@@ -7,6 +7,7 @@ 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.Progress;
using MediaBrowser.Controller;
@@ -75,8 +76,8 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult> AddVirtualFolder(
[FromQuery] string? name,
[FromQuery] string? collectionType,
[FromQuery] string[] paths,
[FromBody] LibraryOptionsDto? libraryOptionsDto,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths,
[FromBody] AddVirtualFolderDto? libraryOptionsDto,
[FromQuery] bool refreshLibrary = false)
{
var libraryOptions = libraryOptionsDto?.LibraryOptions ?? new LibraryOptions();
@@ -312,19 +313,17 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Update library options.
/// </summary>
/// <param name="id">The library name.</param>
/// <param name="libraryOptions">The library options.</param>
/// <param name="request">The library name and options.</param>
/// <response code="204">Library updated.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("LibraryOptions")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateLibraryOptions(
[FromQuery] string? id,
[FromBody] LibraryOptions? libraryOptions)
[FromBody] UpdateLibraryOptionsDto request)
{
var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(id);
var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id);
collectionFolder.UpdateLibraryOptions(libraryOptions);
collectionFolder.UpdateLibraryOptions(request.LibraryOptions);
return NoContent();
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
@@ -9,6 +10,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
@@ -16,6 +18,7 @@ using Jellyfin.Api.Models.LiveTvDtos;
using Jellyfin.Data.Enums;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
@@ -23,6 +26,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Net;
using MediaBrowser.Model.Querying;
@@ -142,7 +146,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? isDisliked,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] string? fields,
[FromQuery] bool? enableUserData,
[FromQuery] string? sortBy,
@@ -208,7 +212,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Channels/{channelId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = Policies.DefaultAuthorization)]
public ActionResult<BaseItemDto> GetChannel([FromRoute] Guid channelId, [FromQuery] Guid? userId)
public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
@@ -259,7 +263,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? seriesTimerId,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] string? fields,
[FromQuery] bool? enableUserData,
[FromQuery] bool? isMovie,
@@ -346,7 +350,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? seriesTimerId,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] string? fields,
[FromQuery] bool? enableUserData,
[FromQuery] bool enableTotalRecordCount = true)
@@ -405,7 +409,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Recordings/{recordingId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = Policies.DefaultAuthorization)]
public ActionResult<BaseItemDto> GetRecording([FromRoute] Guid recordingId, [FromQuery] Guid? userId)
public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
@@ -427,7 +431,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Tuners/{tunerId}/Reset")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[Authorize(Policy = Policies.DefaultAuthorization)]
public ActionResult ResetTuner([FromRoute] string tunerId)
public ActionResult ResetTuner([FromRoute, Required] string tunerId)
{
AssertUserCanManageLiveTv();
_liveTvManager.ResetTuner(tunerId, CancellationToken.None);
@@ -445,7 +449,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Timers/{timerId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = Policies.DefaultAuthorization)]
public async Task<ActionResult<TimerInfoDto>> GetTimer(string timerId)
public async Task<ActionResult<TimerInfoDto>> GetTimer([FromRoute, Required] string timerId)
{
return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false);
}
@@ -557,7 +561,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? genreIds,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] bool? enableUserData,
[FromQuery] string? seriesTimerId,
[FromQuery] Guid? librarySeriesId,
@@ -588,15 +592,15 @@ namespace Jellyfin.Api.Controllers
IsKids = isKids,
IsSports = isSports,
SeriesTimerId = seriesTimerId,
Genres = RequestHelpers.Split(genres, ',', true),
Genres = RequestHelpers.Split(genres, '|', true),
GenreIds = RequestHelpers.GetGuids(genreIds)
};
if (!librarySeriesId.Equals(Guid.Empty))
if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty))
{
query.IsSeries = true;
if (_libraryManager.GetItemById(librarySeriesId ?? Guid.Empty) is Series series)
if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series)
{
query.Name = series.Name;
}
@@ -644,7 +648,7 @@ namespace Jellyfin.Api.Controllers
IsKids = body.IsKids,
IsSports = body.IsSports,
SeriesTimerId = body.SeriesTimerId,
Genres = RequestHelpers.Split(body.Genres, ',', true),
Genres = RequestHelpers.Split(body.Genres, '|', true),
GenreIds = RequestHelpers.GetGuids(body.GenreIds)
};
@@ -701,7 +705,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? isSports,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] string? genreIds,
[FromQuery] string? fields,
[FromQuery] bool? enableUserData,
@@ -743,7 +747,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<BaseItemDto>> GetProgram(
[FromRoute] string programId,
[FromRoute, Required] string programId,
[FromQuery] Guid? userId)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -764,7 +768,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DeleteRecording([FromRoute] Guid recordingId)
public ActionResult DeleteRecording([FromRoute, Required] Guid recordingId)
{
AssertUserCanManageLiveTv();
@@ -791,7 +795,7 @@ namespace Jellyfin.Api.Controllers
[HttpDelete("Timers/{timerId}")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> CancelTimer([FromRoute] string timerId)
public async Task<ActionResult> CancelTimer([FromRoute, Required] string timerId)
{
AssertUserCanManageLiveTv();
await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false);
@@ -809,7 +813,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> UpdateTimer([FromRoute] string timerId, [FromBody] TimerInfoDto timerInfo)
public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo)
{
AssertUserCanManageLiveTv();
await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
@@ -843,7 +847,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SeriesTimerInfoDto>> GetSeriesTimer([FromRoute] string timerId)
public async Task<ActionResult<SeriesTimerInfoDto>> GetSeriesTimer([FromRoute, Required] string timerId)
{
var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false);
if (timer == null)
@@ -883,7 +887,7 @@ namespace Jellyfin.Api.Controllers
[HttpDelete("SeriesTimers/{timerId}")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> CancelSeriesTimer([FromRoute] string timerId)
public async Task<ActionResult> CancelSeriesTimer([FromRoute, Required] string timerId)
{
AssertUserCanManageLiveTv();
await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false);
@@ -901,7 +905,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> UpdateSeriesTimer([FromRoute] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
{
AssertUserCanManageLiveTv();
await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
@@ -933,7 +937,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Obsolete("This endpoint is obsolete.")]
public ActionResult<BaseItemDto> GetRecordingGroup([FromRoute] Guid? groupId)
public ActionResult<BaseItemDto> GetRecordingGroup([FromRoute, Required] Guid groupId)
{
return NotFound();
}
@@ -1004,7 +1008,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="validateLogin">Validate login.</param>
/// <response code="200">Created listings provider returned.</response>
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
[HttpGet("ListingProviders")]
[HttpPost("ListingProviders")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
@@ -1014,9 +1018,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool validateListings = false,
[FromQuery] bool validateLogin = false)
{
using var sha = SHA1.Create();
if (!string.IsNullOrEmpty(pw))
{
using var sha = SHA1.Create();
listingsProviderInfo.Password = Hex.Encode(sha.ComputeHash(Encoding.UTF8.GetBytes(pw)));
}
@@ -1067,9 +1071,10 @@ namespace Jellyfin.Api.Controllers
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesFile(MediaTypeNames.Application.Json)]
public async Task<ActionResult> GetSchedulesDirectCountries()
{
var client = _httpClientFactory.CreateClient();
var client = _httpClientFactory.CreateClient(NamedClient.Default);
// https://json.schedulesdirect.org/20141201/available/countries
// Can't dispose the response as it's required up the call chain.
var response = await client.GetAsync("https://json.schedulesdirect.org/20141201/available/countries")
@@ -1175,7 +1180,8 @@ namespace Jellyfin.Api.Controllers
[HttpGet("LiveRecordings/{recordingId}/stream")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> GetLiveRecordingFile([FromRoute] string recordingId)
[ProducesVideoFile]
public async Task<ActionResult> GetLiveRecordingFile([FromRoute, Required] string recordingId)
{
var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId);
@@ -1205,7 +1211,8 @@ namespace Jellyfin.Api.Controllers
[HttpGet("LiveStreamFiles/{streamId}/stream.{container}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> GetLiveStreamFile([FromRoute] string streamId, [FromRoute] string container)
[ProducesVideoFile]
public async Task<ActionResult> GetLiveStreamFile([FromRoute, Required] string streamId, [FromRoute, Required] string container)
{
var liveStreamInfo = await _mediaSourceManager.GetDirectStreamProviderByUniqueId(streamId, CancellationToken.None).ConfigureAwait(false);
if (liveStreamInfo == null)
@@ -1213,11 +1220,8 @@ namespace Jellyfin.Api.Controllers
return NotFound();
}
await using var memoryStream = new MemoryStream();
await new ProgressiveFileCopier(liveStreamInfo, null, _transcodingJobHelper, CancellationToken.None)
.WriteToAsync(memoryStream, CancellationToken.None)
.ConfigureAwait(false);
return File(memoryStream, MimeTypes.GetMimeType("file." + container));
var liveStream = new ProgressiveFileStream(liveStreamInfo.GetFilePath(), null, _transcodingJobHelper);
return new FileStreamResult(liveStream, MimeTypes.GetMimeType("file." + container));
}
private void AssertUserCanManageLiveTv()

View File

@@ -1,30 +1,20 @@
using System;
using System.Buffers;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Net.Mime;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.MediaInfoDtos;
using Jellyfin.Api.Models.VideoDtos;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Session;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -42,12 +32,9 @@ namespace Jellyfin.Api.Controllers
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IDeviceManager _deviceManager;
private readonly ILibraryManager _libraryManager;
private readonly INetworkManager _networkManager;
private readonly IMediaEncoder _mediaEncoder;
private readonly IUserManager _userManager;
private readonly IAuthorizationContext _authContext;
private readonly ILogger<MediaInfoController> _logger;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly MediaInfoHelper _mediaInfoHelper;
/// <summary>
/// Initializes a new instance of the <see cref="MediaInfoController"/> class.
@@ -55,32 +42,23 @@ namespace Jellyfin.Api.Controllers
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param>
public MediaInfoController(
IMediaSourceManager mediaSourceManager,
IDeviceManager deviceManager,
ILibraryManager libraryManager,
INetworkManager networkManager,
IMediaEncoder mediaEncoder,
IUserManager userManager,
IAuthorizationContext authContext,
ILogger<MediaInfoController> logger,
IServerConfigurationManager serverConfigurationManager)
MediaInfoHelper mediaInfoHelper)
{
_mediaSourceManager = mediaSourceManager;
_deviceManager = deviceManager;
_libraryManager = libraryManager;
_networkManager = networkManager;
_mediaEncoder = mediaEncoder;
_userManager = userManager;
_authContext = authContext;
_logger = logger;
_serverConfigurationManager = serverConfigurationManager;
_mediaInfoHelper = mediaInfoHelper;
}
/// <summary>
@@ -92,9 +70,12 @@ namespace Jellyfin.Api.Controllers
/// <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] Guid itemId, [FromQuery, Required] Guid? userId)
public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery, Required] Guid userId)
{
return await GetPlaybackInfoInternal(itemId, userId).ConfigureAwait(false);
return await _mediaInfoHelper.GetPlaybackInfo(
itemId,
userId)
.ConfigureAwait(false);
}
/// <summary>
@@ -121,9 +102,9 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Items/{itemId}/PlaybackInfo")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
[FromQuery] long? maxStreamingBitrate,
[FromQuery] int? maxStreamingBitrate,
[FromQuery] long? startTimeTicks,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? subtitleStreamIndex,
@@ -153,7 +134,12 @@ namespace Jellyfin.Api.Controllers
}
}
var info = await GetPlaybackInfoInternal(itemId, userId, mediaSourceId, liveStreamId).ConfigureAwait(false);
var info = await _mediaInfoHelper.GetPlaybackInfo(
itemId,
userId,
mediaSourceId,
liveStreamId)
.ConfigureAwait(false);
if (profile != null)
{
@@ -162,7 +148,7 @@ namespace Jellyfin.Api.Controllers
foreach (var mediaSource in info.MediaSources)
{
SetDeviceSpecificData(
_mediaInfoHelper.SetDeviceSpecificData(
item,
mediaSource,
profile,
@@ -179,10 +165,11 @@ namespace Jellyfin.Api.Controllers
enableDirectStream,
enableTranscoding,
allowVideoStreamCopy,
allowAudioStreamCopy);
allowAudioStreamCopy,
Request.HttpContext.GetNormalizedRemoteIp());
}
SortMediaSources(info, maxStreamingBitrate);
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
}
if (autoOpenLiveStream)
@@ -191,21 +178,23 @@ namespace Jellyfin.Api.Controllers
if (mediaSource != null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId))
{
var openStreamResult = await OpenMediaSource(new LiveStreamRequest
{
AudioStreamIndex = audioStreamIndex,
DeviceProfile = deviceProfile?.DeviceProfile,
EnableDirectPlay = enableDirectPlay,
EnableDirectStream = enableDirectStream,
ItemId = itemId,
MaxAudioChannels = maxAudioChannels,
MaxStreamingBitrate = maxStreamingBitrate,
PlaySessionId = info.PlaySessionId,
StartTimeTicks = startTimeTicks,
SubtitleStreamIndex = subtitleStreamIndex,
UserId = userId ?? Guid.Empty,
OpenToken = mediaSource.OpenToken
}).ConfigureAwait(false);
var openStreamResult = await _mediaInfoHelper.OpenMediaSource(
Request,
new LiveStreamRequest
{
AudioStreamIndex = audioStreamIndex,
DeviceProfile = deviceProfile?.DeviceProfile,
EnableDirectPlay = enableDirectPlay,
EnableDirectStream = enableDirectStream,
ItemId = itemId,
MaxAudioChannels = maxAudioChannels,
MaxStreamingBitrate = maxStreamingBitrate,
PlaySessionId = info.PlaySessionId,
StartTimeTicks = startTimeTicks,
SubtitleStreamIndex = subtitleStreamIndex,
UserId = userId ?? Guid.Empty,
OpenToken = mediaSource.OpenToken
}).ConfigureAwait(false);
info.MediaSources = new[] { openStreamResult.MediaSource };
}
@@ -215,7 +204,7 @@ namespace Jellyfin.Api.Controllers
{
foreach (var mediaSource in info.MediaSources)
{
NormalizeMediaSourceContainer(mediaSource, profile!, DlnaProfileType.Video);
_mediaInfoHelper.NormalizeMediaSourceContainer(mediaSource, profile!, DlnaProfileType.Video);
}
}
@@ -245,7 +234,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? openToken,
[FromQuery] Guid? userId,
[FromQuery] string? playSessionId,
[FromQuery] long? maxStreamingBitrate,
[FromQuery] int? maxStreamingBitrate,
[FromQuery] long? startTimeTicks,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? subtitleStreamIndex,
@@ -271,7 +260,7 @@ namespace Jellyfin.Api.Controllers
EnableDirectStream = enableDirectStream,
DirectPlayProtocols = openLiveStreamDto?.DirectPlayProtocols ?? new[] { MediaProtocol.Http }
};
return await OpenMediaSource(request).ConfigureAwait(false);
return await _mediaInfoHelper.OpenMediaSource(Request, request).ConfigureAwait(false);
}
/// <summary>
@@ -282,9 +271,9 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("LiveStreams/Close")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult CloseLiveStream([FromQuery, Required] string? liveStreamId)
public async Task<ActionResult> CloseLiveStream([FromQuery, Required] string liveStreamId)
{
_mediaSourceManager.CloseLiveStream(liveStreamId).GetAwaiter().GetResult();
await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
return NoContent();
}
@@ -299,6 +288,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[Produces(MediaTypeNames.Application.Octet)]
[ProducesFile(MediaTypeNames.Application.Octet)]
public ActionResult GetBitrateTestBytes([FromQuery] int size = 102400)
{
const int MaxSize = 10_000_000;
@@ -324,454 +314,5 @@ namespace Jellyfin.Api.Controllers
ArrayPool<byte>.Shared.Return(buffer);
}
}
private async Task<PlaybackInfoResponse> GetPlaybackInfoInternal(
Guid id,
Guid? userId,
string? mediaSourceId = null,
string? liveStreamId = null)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
: null;
var item = _libraryManager.GetItemById(id);
var result = new PlaybackInfoResponse();
MediaSourceInfo[] mediaSources;
if (string.IsNullOrWhiteSpace(liveStreamId))
{
// TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(mediaSourceId))
{
mediaSources = mediaSourcesList.ToArray();
}
else
{
mediaSources = mediaSourcesList
.Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
.ToArray();
}
}
else
{
var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
mediaSources = new[] { mediaSource };
}
if (mediaSources.Length == 0)
{
result.MediaSources = Array.Empty<MediaSourceInfo>();
result.ErrorCode ??= PlaybackErrorCode.NoCompatibleStream;
}
else
{
// Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it
// Should we move this directly into MediaSourceManager?
result.MediaSources = JsonSerializer.Deserialize<MediaSourceInfo[]>(JsonSerializer.SerializeToUtf8Bytes(mediaSources));
result.PlaySessionId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
}
return result;
}
private void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type)
{
mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type);
}
private void SetDeviceSpecificData(
BaseItem item,
MediaSourceInfo mediaSource,
DeviceProfile profile,
AuthorizationInfo auth,
long? maxBitrate,
long startTimeTicks,
string mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
int? maxAudioChannels,
string playSessionId,
Guid userId,
bool enableDirectPlay,
bool enableDirectStream,
bool enableTranscoding,
bool allowVideoStreamCopy,
bool allowAudioStreamCopy)
{
var streamBuilder = new StreamBuilder(_mediaEncoder, _logger);
var options = new VideoOptions
{
MediaSources = new[] { mediaSource },
Context = EncodingContext.Streaming,
DeviceId = auth.DeviceId,
ItemId = item.Id,
Profile = profile,
MaxAudioChannels = maxAudioChannels
};
if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase))
{
options.MediaSourceId = mediaSourceId;
options.AudioStreamIndex = audioStreamIndex;
options.SubtitleStreamIndex = subtitleStreamIndex;
}
var user = _userManager.GetUserById(userId);
if (!enableDirectPlay)
{
mediaSource.SupportsDirectPlay = false;
}
if (!enableDirectStream)
{
mediaSource.SupportsDirectStream = false;
}
if (!enableTranscoding)
{
mediaSource.SupportsTranscoding = false;
}
if (item is Audio)
{
_logger.LogInformation(
"User policy for {0}. EnableAudioPlaybackTranscoding: {1}",
user.Username,
user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
}
else
{
_logger.LogInformation(
"User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}",
user.Username,
user.HasPermission(PermissionKind.EnablePlaybackRemuxing),
user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding),
user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding));
}
// Beginning of Playback Determination: Attempt DirectPlay first
if (mediaSource.SupportsDirectPlay)
{
if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
{
mediaSource.SupportsDirectPlay = false;
}
else
{
var supportsDirectStream = mediaSource.SupportsDirectStream;
// Dummy this up to fool StreamBuilder
mediaSource.SupportsDirectStream = true;
options.MaxBitrate = maxBitrate;
if (item is Audio)
{
if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
{
options.ForceDirectPlay = true;
}
}
else if (item is Video)
{
if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
&& !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
&& !user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
{
options.ForceDirectPlay = true;
}
}
// The MediaSource supports direct stream, now test to see if the client supports it
var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
? streamBuilder.BuildAudioItem(options)
: streamBuilder.BuildVideoItem(options);
if (streamInfo == null || !streamInfo.IsDirectStream)
{
mediaSource.SupportsDirectPlay = false;
}
// Set this back to what it was
mediaSource.SupportsDirectStream = supportsDirectStream;
if (streamInfo != null)
{
SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
}
}
}
if (mediaSource.SupportsDirectStream)
{
if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
{
mediaSource.SupportsDirectStream = false;
}
else
{
options.MaxBitrate = GetMaxBitrate(maxBitrate, user);
if (item is Audio)
{
if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding))
{
options.ForceDirectStream = true;
}
}
else if (item is Video)
{
if (!user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding)
&& !user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding)
&& !user.HasPermission(PermissionKind.EnablePlaybackRemuxing))
{
options.ForceDirectStream = true;
}
}
// The MediaSource supports direct stream, now test to see if the client supports it
var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
? streamBuilder.BuildAudioItem(options)
: streamBuilder.BuildVideoItem(options);
if (streamInfo == null || !streamInfo.IsDirectStream)
{
mediaSource.SupportsDirectStream = false;
}
if (streamInfo != null)
{
SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
}
}
}
if (mediaSource.SupportsTranscoding)
{
options.MaxBitrate = GetMaxBitrate(maxBitrate, user);
// The MediaSource supports direct stream, now test to see if the client supports it
var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase)
? streamBuilder.BuildAudioItem(options)
: streamBuilder.BuildVideoItem(options);
if (mediaSource.IsRemote && user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding))
{
if (streamInfo != null)
{
streamInfo.PlaySessionId = playSessionId;
streamInfo.StartPositionTicks = startTimeTicks;
mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-');
mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
mediaSource.TranscodingContainer = streamInfo.Container;
mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
// Do this after the above so that StartPositionTicks is set
SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
}
}
else
{
if (streamInfo != null)
{
streamInfo.PlaySessionId = playSessionId;
if (streamInfo.PlayMethod == PlayMethod.Transcode)
{
streamInfo.StartPositionTicks = startTimeTicks;
mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-');
if (!allowVideoStreamCopy)
{
mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false";
}
if (!allowAudioStreamCopy)
{
mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
}
mediaSource.TranscodingContainer = streamInfo.Container;
mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
}
if (!allowAudioStreamCopy)
{
mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false";
}
mediaSource.TranscodingContainer = streamInfo.Container;
mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol;
// Do this after the above so that StartPositionTicks is set
SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token);
}
}
}
foreach (var attachment in mediaSource.MediaAttachments)
{
attachment.DeliveryUrl = string.Format(
CultureInfo.InvariantCulture,
"/Videos/{0}/{1}/Attachments/{2}",
item.Id,
mediaSource.Id,
attachment.Index);
}
}
private async Task<LiveStreamResponse> OpenMediaSource(LiveStreamRequest request)
{
var authInfo = _authContext.GetAuthorizationInfo(Request);
var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
var profile = request.DeviceProfile;
if (profile == null)
{
var caps = _deviceManager.GetCapabilities(authInfo.DeviceId);
if (caps != null)
{
profile = caps.DeviceProfile;
}
}
if (profile != null)
{
var item = _libraryManager.GetItemById(request.ItemId);
SetDeviceSpecificData(
item,
result.MediaSource,
profile,
authInfo,
request.MaxStreamingBitrate,
request.StartTimeTicks ?? 0,
result.MediaSource.Id,
request.AudioStreamIndex,
request.SubtitleStreamIndex,
request.MaxAudioChannels,
request.PlaySessionId,
request.UserId,
request.EnableDirectPlay,
request.EnableDirectStream,
true,
true,
true);
}
else
{
if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl))
{
result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId;
}
}
// here was a check if (result.MediaSource != null) but Rider said it will never be null
NormalizeMediaSourceContainer(result.MediaSource, profile!, DlnaProfileType.Video);
return result;
}
private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken)
{
var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken);
mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex;
mediaSource.TranscodeReasons = info.TranscodeReasons;
foreach (var profile in profiles)
{
foreach (var stream in mediaSource.MediaStreams)
{
if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index)
{
stream.DeliveryMethod = profile.DeliveryMethod;
if (profile.DeliveryMethod == SubtitleDeliveryMethod.External)
{
stream.DeliveryUrl = profile.Url.TrimStart('-');
stream.IsExternalUrl = profile.IsExternalUrl;
}
}
}
}
}
private long? GetMaxBitrate(long? clientMaxBitrate, User user)
{
var maxBitrate = clientMaxBitrate;
var remoteClientMaxBitrate = user?.RemoteClientBitrateLimit ?? 0;
if (remoteClientMaxBitrate <= 0)
{
remoteClientMaxBitrate = _serverConfigurationManager.Configuration.RemoteClientBitrateLimit;
}
if (remoteClientMaxBitrate > 0)
{
var isInLocalNetwork = _networkManager.IsInLocalNetwork(Request.HttpContext.Connection.RemoteIpAddress.ToString());
_logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, Request.HttpContext.Connection.RemoteIpAddress.ToString(), isInLocalNetwork);
if (!isInLocalNetwork)
{
maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate);
}
}
return maxBitrate;
}
private void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
{
var originalList = result.MediaSources.ToList();
result.MediaSources = result.MediaSources.OrderBy(i =>
{
// Nothing beats direct playing a file
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
{
return 0;
}
return 1;
})
.ThenBy(i =>
{
// Let's assume direct streaming a file is just as desirable as direct playing a remote url
if (i.SupportsDirectPlay || i.SupportsDirectStream)
{
return 0;
}
return 1;
})
.ThenBy(i =>
{
return i.Protocol switch
{
MediaProtocol.File => 0,
_ => 1,
};
})
.ThenBy(i =>
{
if (maxBitrate.HasValue && i.Bitrate.HasValue)
{
return i.Bitrate.Value <= maxBitrate.Value ? 0 : 2;
}
return 1;
})
.ThenBy(originalList.IndexOf)
.ToArray();
}
}
}

View File

@@ -10,6 +10,7 @@ using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
@@ -84,8 +85,8 @@ namespace Jellyfin.Api.Controllers
IncludeItemTypes = new[]
{
nameof(Movie),
// typeof(Trailer).Name,
// typeof(LiveTvProgram).Name
// nameof(Trailer),
// nameof(LiveTvProgram)
},
// IsMovie = true
OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
@@ -181,7 +182,7 @@ namespace Jellyfin.Api.Controllers
DtoOptions dtoOptions,
RecommendationType type)
{
var itemTypes = new List<string> { nameof(MediaBrowser.Controller.Entities.Movies.Movie) };
var itemTypes = new List<string> { nameof(Movie) };
if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
{
itemTypes.Add(nameof(Trailer));

View File

@@ -1,5 +1,5 @@
using System;
using System.Globalization;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -46,7 +47,6 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Gets all music genres from a given item, folder, or the entire library.
/// </summary>
/// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="searchTerm">The search term.</param>
@@ -54,22 +54,9 @@ namespace Jellyfin.Api.Controllers
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
/// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
/// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
/// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
/// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
/// <param name="enableUserData">Optional, include user data.</param>
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
/// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
/// <param name="userId">User id.</param>
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -79,8 +66,8 @@ namespace Jellyfin.Api.Controllers
/// <response code="200">Music genres returned.</response>
/// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns>
[HttpGet]
[Obsolete("Use GetGenres instead")]
public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
[FromQuery] double? minCommunityRating,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
@@ -88,22 +75,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? fields,
[FromQuery] string? excludeItemTypes,
[FromQuery] string? includeItemTypes,
[FromQuery] string? filters,
[FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes,
[FromQuery] string? genres,
[FromQuery] string? genreIds,
[FromQuery] string? officialRatings,
[FromQuery] string? tags,
[FromQuery] string? years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] string? person,
[FromQuery] string? personIds,
[FromQuery] string? personTypes,
[FromQuery] string? studios,
[FromQuery] string? studioIds,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] Guid? userId,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
@@ -114,42 +88,22 @@ namespace Jellyfin.Api.Controllers
var dtoOptions = new DtoOptions()
.AddItemFields(fields)
.AddClientFields(Request)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
User? user = null;
BaseItem parentItem;
User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null;
if (userId.HasValue && !userId.Equals(Guid.Empty))
{
user = _userManager.GetUserById(userId.Value);
parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
}
else
{
parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
}
var parentItem = _libraryManager.GetParentItem(parentId, userId);
var query = new InternalItemsQuery(user)
{
ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
Tags = RequestHelpers.Split(tags, '|', true),
OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
Genres = RequestHelpers.Split(genres, '|', true),
GenreIds = RequestHelpers.GetGuids(genreIds),
StudioIds = RequestHelpers.GetGuids(studioIds),
Person = person,
PersonIds = RequestHelpers.GetGuids(personIds),
PersonTypes = RequestHelpers.Split(personTypes, ',', true),
Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
MinCommunityRating = minCommunityRating,
DtoOptions = dtoOptions,
SearchTerm = searchTerm,
EnableTotalRecordCount = enableTotalRecordCount
@@ -167,87 +121,10 @@ namespace Jellyfin.Api.Controllers
}
}
// Studios
if (!string.IsNullOrEmpty(studios))
{
query.StudioIds = studios.Split('|')
.Select(i =>
{
try
{
return _libraryManager.GetStudio(i);
}
catch
{
return null;
}
}).Where(i => i != null)
.Select(i => i!.Id)
.ToArray();
}
foreach (var filter in RequestHelpers.GetFilters(filters))
{
switch (filter)
{
case ItemFilter.Dislikes:
query.IsLiked = false;
break;
case ItemFilter.IsFavorite:
query.IsFavorite = true;
break;
case ItemFilter.IsFavoriteOrLikes:
query.IsFavoriteOrLiked = true;
break;
case ItemFilter.IsFolder:
query.IsFolder = true;
break;
case ItemFilter.IsNotFolder:
query.IsFolder = false;
break;
case ItemFilter.IsPlayed:
query.IsPlayed = true;
break;
case ItemFilter.IsResumable:
query.IsResumable = true;
break;
case ItemFilter.IsUnplayed:
query.IsPlayed = false;
break;
case ItemFilter.Likes:
query.IsLiked = true;
break;
}
}
var result = _libraryManager.GetMusicGenres(query);
var dtos = result.Items.Select(i =>
{
var (baseItem, counts) = i;
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
if (!string.IsNullOrWhiteSpace(includeItemTypes))
{
dto.ChildCount = counts.ItemCount;
dto.ProgramCount = counts.ProgramCount;
dto.SeriesCount = counts.SeriesCount;
dto.EpisodeCount = counts.EpisodeCount;
dto.MovieCount = counts.MovieCount;
dto.TrailerCount = counts.TrailerCount;
dto.AlbumCount = counts.AlbumCount;
dto.SongCount = counts.SongCount;
dto.ArtistCount = counts.ArtistCount;
}
return dto;
});
return new QueryResult<BaseItemDto>
{
Items = dtos.ToArray(),
TotalRecordCount = result.TotalRecordCount
};
var shouldIncludeItemTypes = !string.IsNullOrWhiteSpace(includeItemTypes);
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
/// <summary>
@@ -258,7 +135,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns>
[HttpGet("{genreName}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<BaseItemDto> GetMusicGenre([FromRoute] string genreName, [FromQuery] Guid? userId)
public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
{
var dtoOptions = new DtoOptions().AddClientFields(Request);

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading;
using Jellyfin.Api.Constants;

View File

@@ -44,14 +44,15 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Packages/{name}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<PackageInfo>> GetPackageInfo(
[FromRoute] [Required] string? name,
[FromRoute, Required] string name,
[FromQuery] string? assemblyGuid)
{
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
var result = _installationManager.FilterPackages(
packages,
name,
string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid)).FirstOrDefault();
packages,
name,
string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid))
.FirstOrDefault();
return result;
}
@@ -76,6 +77,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="name">Package name.</param>
/// <param name="assemblyGuid">GUID of the associated assembly.</param>
/// <param name="version">Optional version. Defaults to latest version.</param>
/// <param name="repositoryUrl">Optional. Specify the repository to install from.</param>
/// <response code="204">Package found.</response>
/// <response code="404">Package not found.</response>
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns>
@@ -84,16 +86,24 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Authorize(Policy = Policies.RequiresElevation)]
public async Task<ActionResult> InstallPackage(
[FromRoute] [Required] string? name,
[FromRoute, Required] string name,
[FromQuery] string? assemblyGuid,
[FromQuery] string? version)
[FromQuery] string? version,
[FromQuery] string? repositoryUrl)
{
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
if (!string.IsNullOrEmpty(repositoryUrl))
{
packages = packages.Where(p => p.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))
.ToList();
}
var package = _installationManager.GetCompatibleVersions(
packages,
name,
string.IsNullOrEmpty(assemblyGuid) ? Guid.Empty : Guid.Parse(assemblyGuid),
string.IsNullOrEmpty(version) ? null : Version.Parse(version)).FirstOrDefault();
specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version))
.FirstOrDefault();
if (package == null)
{
@@ -115,7 +125,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult CancelPackageInstallation(
[FromRoute] [Required] Guid packageId)
[FromRoute, Required] Guid packageId)
{
_installationManager.CancelInstallation(packageId);
return NoContent();

View File

@@ -1,14 +1,16 @@
using System;
using System.Globalization;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Entities;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -25,6 +27,7 @@ namespace Jellyfin.Api.Controllers
private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService;
private readonly IUserManager _userManager;
private readonly IUserDataManager _userDataManager;
/// <summary>
/// Initializes a new instance of the <see cref="PersonsController"/> class.
@@ -32,84 +35,53 @@ namespace Jellyfin.Api.Controllers
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
public PersonsController(
ILibraryManager libraryManager,
IDtoService dtoService,
IUserManager userManager)
IUserManager userManager,
IUserDataManager userDataManager)
{
_libraryManager = libraryManager;
_dtoService = dtoService;
_userManager = userManager;
_userDataManager = userDataManager;
}
/// <summary>
/// Gets all persons from a given item, folder, or the entire library.
/// Gets all persons.
/// </summary>
/// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="searchTerm">The search term.</param>
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
/// <param name="includeItemTypes">Optional. If specified, results will be filtered in based on item type. This allows multiple, comma delimited.</param>
/// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
/// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
/// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not. userId is required.</param>
/// <param name="enableUserData">Optional, include user data.</param>
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
/// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
/// <param name="excludePersonTypes">Optional. If specified results will be filtered to exclude those containing the specified PersonType. Allows multiple, comma-delimited.</param>
/// <param name="personTypes">Optional. If specified results will be filtered to include only those containing the specified PersonType. Allows multiple, comma-delimited.</param>
/// <param name="appearsInItemId">Optional. If specified, person results will be filtered on items related to said persons.</param>
/// <param name="userId">User id.</param>
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
/// <param name="enableImages">Optional, include image information in output.</param>
/// <param name="enableTotalRecordCount">Optional. Include total record count.</param>
/// <response code="200">Persons returned.</response>
/// <returns>An <see cref="OkResult"/> containing the queryresult of persons.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetPersons(
[FromQuery] double? minCommunityRating,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
[FromQuery] string? parentId,
[FromQuery] string? fields,
[FromQuery] string? excludeItemTypes,
[FromQuery] string? includeItemTypes,
[FromQuery] string? filters,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes,
[FromQuery] string? genres,
[FromQuery] string? genreIds,
[FromQuery] string? officialRatings,
[FromQuery] string? tags,
[FromQuery] string? years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] string? person,
[FromQuery] string? personIds,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] string? excludePersonTypes,
[FromQuery] string? personTypes,
[FromQuery] string? studios,
[FromQuery] string? studioIds,
[FromQuery] string? appearsInItemId,
[FromQuery] Guid? userId,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
[FromQuery] string? nameLessThan,
[FromQuery] bool? enableImages = true,
[FromQuery] bool enableTotalRecordCount = true)
[FromQuery] bool? enableImages = true)
{
var dtoOptions = new DtoOptions()
.AddItemFields(fields)
@@ -117,136 +89,28 @@ namespace Jellyfin.Api.Controllers
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
User? user = null;
BaseItem parentItem;
if (userId.HasValue && !userId.Equals(Guid.Empty))
{
user = _userManager.GetUserById(userId.Value);
parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
}
else
{
parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
}
var query = new InternalItemsQuery(user)
var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery
{
ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
Tags = RequestHelpers.Split(tags, '|', true),
OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
Genres = RequestHelpers.Split(genres, '|', true),
GenreIds = RequestHelpers.GetGuids(genreIds),
StudioIds = RequestHelpers.GetGuids(studioIds),
Person = person,
PersonIds = RequestHelpers.GetGuids(personIds),
PersonTypes = RequestHelpers.Split(personTypes, ',', true),
Years = RequestHelpers.Split(years, ',', true).Select(y => Convert.ToInt32(y, CultureInfo.InvariantCulture)).ToArray(),
MinCommunityRating = minCommunityRating,
DtoOptions = dtoOptions,
SearchTerm = searchTerm,
EnableTotalRecordCount = enableTotalRecordCount
};
if (!string.IsNullOrWhiteSpace(parentId))
{
if (parentItem is Folder)
{
query.AncestorIds = new[] { new Guid(parentId) };
}
else
{
query.ItemIds = new[] { new Guid(parentId) };
}
}
// Studios
if (!string.IsNullOrEmpty(studios))
{
query.StudioIds = studios.Split('|')
.Select(i =>
{
try
{
return _libraryManager.GetStudio(i);
}
catch
{
return null;
}
}).Where(i => i != null)
.Select(i => i!.Id)
.ToArray();
}
foreach (var filter in RequestHelpers.GetFilters(filters))
{
switch (filter)
{
case ItemFilter.Dislikes:
query.IsLiked = false;
break;
case ItemFilter.IsFavorite:
query.IsFavorite = true;
break;
case ItemFilter.IsFavoriteOrLikes:
query.IsFavoriteOrLiked = true;
break;
case ItemFilter.IsFolder:
query.IsFolder = true;
break;
case ItemFilter.IsNotFolder:
query.IsFolder = false;
break;
case ItemFilter.IsPlayed:
query.IsPlayed = true;
break;
case ItemFilter.IsResumable:
query.IsResumable = true;
break;
case ItemFilter.IsUnplayed:
query.IsPlayed = false;
break;
case ItemFilter.Likes:
query.IsLiked = true;
break;
}
}
var result = new QueryResult<(BaseItem, ItemCounts)>();
var dtos = result.Items.Select(i =>
{
var (baseItem, counts) = i;
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
if (!string.IsNullOrWhiteSpace(includeItemTypes))
{
dto.ChildCount = counts.ItemCount;
dto.ProgramCount = counts.ProgramCount;
dto.SeriesCount = counts.SeriesCount;
dto.EpisodeCount = counts.EpisodeCount;
dto.MovieCount = counts.MovieCount;
dto.TrailerCount = counts.TrailerCount;
dto.AlbumCount = counts.AlbumCount;
dto.SongCount = counts.SongCount;
dto.ArtistCount = counts.ArtistCount;
}
return dto;
ExcludePersonTypes = RequestHelpers.Split(excludePersonTypes, ',', true),
NameContains = searchTerm,
User = user,
IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,
AppearsInItemId = string.IsNullOrEmpty(appearsInItemId) ? Guid.Empty : Guid.Parse(appearsInItemId),
Limit = limit ?? 0
});
return new QueryResult<BaseItemDto>
{
Items = dtos.ToArray(),
TotalRecordCount = result.TotalRecordCount
Items = peopleItems.Select(person => _dtoService.GetItemByNameDto(person, dtoOptions, null, user)).ToArray(),
TotalRecordCount = peopleItems.Count
};
}
@@ -262,7 +126,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("{name}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<BaseItemDto> GetPerson([FromRoute] string name, [FromQuery] Guid? userId)
public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId)
{
var dtoOptions = new DtoOptions()
.AddClientFields(Request);

View File

@@ -10,6 +10,7 @@ using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Playlists;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
@@ -83,12 +84,12 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpPost("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult AddToPlaylist(
[FromRoute] string? playlistId,
public async Task<ActionResult> AddToPlaylist(
[FromRoute, Required] Guid playlistId,
[FromQuery] string? ids,
[FromQuery] Guid? userId)
{
_playlistManager.AddToPlaylist(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty);
await _playlistManager.AddToPlaylistAsync(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty).ConfigureAwait(false);
return NoContent();
}
@@ -102,12 +103,12 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult MoveItem(
[FromRoute] string? playlistId,
[FromRoute] string? itemId,
[FromRoute] int newIndex)
public async Task<ActionResult> MoveItem(
[FromRoute, Required] string playlistId,
[FromRoute, Required] string itemId,
[FromRoute, Required] int newIndex)
{
_playlistManager.MoveItem(playlistId, itemId, newIndex);
await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false);
return NoContent();
}
@@ -120,9 +121,9 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpDelete("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult RemoveFromPlaylist([FromRoute] string? playlistId, [FromQuery] string? entryIds)
public async Task<ActionResult> RemoveFromPlaylist([FromRoute, Required] string playlistId, [FromQuery] string? entryIds)
{
_playlistManager.RemoveFromPlaylist(playlistId, RequestHelpers.Split(entryIds, ',', true));
await _playlistManager.RemoveFromPlaylistAsync(playlistId, RequestHelpers.Split(entryIds, ',', true)).ConfigureAwait(false);
return NoContent();
}
@@ -143,15 +144,15 @@ namespace Jellyfin.Api.Controllers
/// <returns>The original playlist items.</returns>
[HttpGet("{playlistId}/Items")]
public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems(
[FromRoute] Guid playlistId,
[FromRoute] Guid userId,
[FromRoute] int? startIndex,
[FromRoute] int? limit,
[FromRoute] string? fields,
[FromRoute] bool? enableImages,
[FromRoute] bool? enableUserData,
[FromRoute] int? imageTypeLimit,
[FromRoute] string? enableImageTypes)
[FromRoute, Required] Guid playlistId,
[FromQuery, Required] Guid userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? fields,
[FromQuery] bool? enableImages,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] ImageType[] enableImageTypes)
{
var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
if (playlist == null)

View File

@@ -1,4 +1,5 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
@@ -71,8 +72,8 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Users/{userId}/PlayedItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<UserItemDataDto> MarkPlayedItem(
[FromRoute] Guid userId,
[FromRoute] Guid itemId,
[FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId,
[FromQuery] DateTime? datePlayed)
{
var user = _userManager.GetUserById(userId);
@@ -94,9 +95,9 @@ namespace Jellyfin.Api.Controllers
/// <param name="itemId">Item id.</param>
/// <response code="200">Item marked as unplayed.</response>
/// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
[HttpDelete("Users/{userId}/PlayedItem/{itemId}")]
[HttpDelete("Users/{userId}/PlayedItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<UserItemDataDto> MarkUnplayedItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
public ActionResult<UserItemDataDto> MarkUnplayedItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
{
var user = _userManager.GetUserById(userId);
var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
@@ -195,8 +196,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
public async Task<ActionResult> OnPlaybackStart(
[FromRoute] Guid userId,
[FromRoute] Guid itemId,
[FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId,
[FromQuery] string? mediaSourceId,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? subtitleStreamIndex,
@@ -245,8 +246,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
public async Task<ActionResult> OnPlaybackProgress(
[FromRoute] Guid userId,
[FromRoute] Guid itemId,
[FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId,
[FromQuery] string? mediaSourceId,
[FromQuery] long? positionTicks,
[FromQuery] int? audioStreamIndex,
@@ -297,8 +298,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
public async Task<ActionResult> OnPlaybackStopped(
[FromRoute] Guid userId,
[FromRoute] Guid itemId,
[FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId,
[FromQuery] string? mediaSourceId,
[FromQuery] string? nextMediaType,
[FromQuery] long? positionTicks,

View File

@@ -64,7 +64,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UninstallPlugin([FromRoute] Guid pluginId)
public ActionResult UninstallPlugin([FromRoute, Required] Guid pluginId)
{
var plugin = _appHost.Plugins.FirstOrDefault(p => p.Id == pluginId);
if (plugin == null)
@@ -86,7 +86,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("{pluginId}/Configuration")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute] Guid pluginId)
public ActionResult<BasePluginConfiguration> GetPluginConfiguration([FromRoute, Required] Guid pluginId)
{
if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
{
@@ -113,17 +113,21 @@ namespace Jellyfin.Api.Controllers
[HttpPost("{pluginId}/Configuration")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdatePluginConfiguration([FromRoute] Guid pluginId)
public async Task<ActionResult> UpdatePluginConfiguration([FromRoute, Required] Guid pluginId)
{
if (!(_appHost.Plugins.FirstOrDefault(p => p.Id == pluginId) is IHasPluginConfiguration plugin))
{
return NotFound();
}
var configuration = (BasePluginConfiguration)await JsonSerializer.DeserializeAsync(Request.Body, plugin.ConfigurationType, _serializerOptions)
var configuration = (BasePluginConfiguration?)await JsonSerializer.DeserializeAsync(Request.Body, plugin.ConfigurationType, _serializerOptions)
.ConfigureAwait(false);
plugin.UpdateConfiguration(configuration);
if (configuration != null)
{
plugin.UpdateConfiguration(configuration);
}
return NoContent();
}
@@ -168,7 +172,7 @@ namespace Jellyfin.Api.Controllers
[Obsolete("This endpoint should not be used.")]
[HttpPost("RegistrationRecords/{name}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute] string? name)
public ActionResult<MBRegistrationRecord> GetRegistrationStatus([FromRoute, Required] string name)
{
return new MBRegistrationRecord
{
@@ -190,7 +194,7 @@ namespace Jellyfin.Api.Controllers
[Obsolete("Paid plugins are not supported")]
[HttpGet("Registrations/{name}")]
[ProducesResponseType(StatusCodes.Status501NotImplemented)]
public ActionResult GetRegistration([FromRoute] string? name)
public ActionResult GetRegistration([FromRoute, Required] string name)
{
// TODO Once we have proper apps and plugins and decide to break compatibility with paid plugins,
// delete all these registration endpoints. They are only kept for compatibility.

View File

@@ -0,0 +1,154 @@
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Model.QuickConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Api.Controllers
{
/// <summary>
/// Quick connect controller.
/// </summary>
public class QuickConnectController : BaseJellyfinApiController
{
private readonly IQuickConnect _quickConnect;
/// <summary>
/// Initializes a new instance of the <see cref="QuickConnectController"/> class.
/// </summary>
/// <param name="quickConnect">Instance of the <see cref="IQuickConnect"/> interface.</param>
public QuickConnectController(IQuickConnect quickConnect)
{
_quickConnect = quickConnect;
}
/// <summary>
/// Gets the current quick connect state.
/// </summary>
/// <response code="200">Quick connect state returned.</response>
/// <returns>The current <see cref="QuickConnectState"/>.</returns>
[HttpGet("Status")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QuickConnectState> GetStatus()
{
_quickConnect.ExpireRequests();
return _quickConnect.State;
}
/// <summary>
/// Initiate a new quick connect request.
/// </summary>
/// <response code="200">Quick connect request successfully created.</response>
/// <response code="401">Quick connect is not active on this server.</response>
/// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns>
[HttpGet("Initiate")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QuickConnectResult> Initiate()
{
return _quickConnect.TryConnect();
}
/// <summary>
/// Attempts to retrieve authentication information.
/// </summary>
/// <param name="secret">Secret previously returned from the Initiate endpoint.</param>
/// <response code="200">Quick connect result returned.</response>
/// <response code="404">Unknown quick connect secret.</response>
/// <returns>An updated <see cref="QuickConnectResult"/>.</returns>
[HttpGet("Connect")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QuickConnectResult> Connect([FromQuery, Required] string secret)
{
try
{
return _quickConnect.CheckRequestStatus(secret);
}
catch (ResourceNotFoundException)
{
return NotFound("Unknown secret");
}
}
/// <summary>
/// Temporarily activates quick connect for five minutes.
/// </summary>
/// <response code="204">Quick connect has been temporarily activated.</response>
/// <response code="403">Quick connect is unavailable on this server.</response>
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpPost("Activate")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult Activate()
{
if (_quickConnect.State == QuickConnectState.Unavailable)
{
return Forbid("Quick connect is unavailable");
}
_quickConnect.Activate();
return NoContent();
}
/// <summary>
/// Enables or disables quick connect.
/// </summary>
/// <param name="status">New <see cref="QuickConnectState"/>.</param>
/// <response code="204">Quick connect state set successfully.</response>
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpPost("Available")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult Available([FromQuery] QuickConnectState status = QuickConnectState.Available)
{
_quickConnect.SetState(status);
return NoContent();
}
/// <summary>
/// Authorizes a pending quick connect request.
/// </summary>
/// <param name="code">Quick connect code to authorize.</param>
/// <response code="200">Quick connect result authorized successfully.</response>
/// <response code="403">Unknown user id.</response>
/// <returns>Boolean indicating if the authorization was successful.</returns>
[HttpPost("Authorize")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult<bool> Authorize([FromQuery, Required] string code)
{
var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
if (!userId.HasValue)
{
return Forbid("Unknown user id");
}
return _quickConnect.AuthorizeRequest(userId.Value, code);
}
/// <summary>
/// Deauthorize all quick connect devices for the current user.
/// </summary>
/// <response code="200">All quick connect devices were deleted.</response>
/// <returns>The number of devices that were deleted.</returns>
[HttpPost("Deauthorize")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<int> Deauthorize()
{
var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
if (!userId.HasValue)
{
return 0;
}
return _quickConnect.DeleteAllDevices(userId.Value);
}
}
}

View File

@@ -7,8 +7,10 @@ using System.Net.Http;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
@@ -69,7 +71,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RemoteImageResult>> GetRemoteImages(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromQuery] ImageType? type,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
@@ -132,7 +134,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute] Guid itemId)
public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -154,6 +156,7 @@ namespace Jellyfin.Api.Controllers
[Produces(MediaTypeNames.Application.Octet)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetRemoteImage([FromQuery, Required] string imageUrl)
{
var urlHash = imageUrl.GetMD5();
@@ -191,7 +194,7 @@ namespace Jellyfin.Api.Controllers
}
var contentType = MimeTypes.GetMimeType(contentPath);
return File(System.IO.File.OpenRead(contentPath), contentType);
return PhysicalFile(contentPath, contentType);
}
/// <summary>
@@ -208,7 +211,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DownloadRemoteImage(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromQuery, Required] ImageType type,
[FromQuery] string? imageUrl)
{
@@ -221,7 +224,7 @@ namespace Jellyfin.Api.Controllers
await _providerManager.SaveImage(item, imageUrl, type, null, CancellationToken.None)
.ConfigureAwait(false);
item.UpdateToRepository(ItemUpdateType.ImageUpdate, CancellationToken.None);
await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
return NoContent();
}
@@ -244,7 +247,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>Task.</returns>
private async Task DownloadImage(string url, Guid urlHash, string pointerCachePath)
{
var httpClient = _httpClientFactory.CreateClient();
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
using var response = await httpClient.GetAsync(url).ConfigureAwait(false);
var ext = response.Content.Headers.ContentType.MediaType.Split('/').Last();
var fullCachePath = GetFullCachePath(urlHash + "." + ext);

View File

@@ -36,7 +36,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>The list of scheduled tasks.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public IEnumerable<IScheduledTaskWorker> GetTasks(
public IEnumerable<TaskInfo> GetTasks(
[FromQuery] bool? isHidden,
[FromQuery] bool? isEnabled)
{
@@ -57,7 +57,7 @@ namespace Jellyfin.Api.Controllers
}
}
yield return task;
yield return ScheduledTaskHelpers.GetTaskInfo(task);
}
}
@@ -71,7 +71,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("{taskId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<TaskInfo> GetTask([FromRoute, Required] string? taskId)
public ActionResult<TaskInfo> GetTask([FromRoute, Required] string taskId)
{
var task = _taskManager.ScheduledTasks.FirstOrDefault(i =>
string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase));
@@ -94,7 +94,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Running/{taskId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult StartTask([FromRoute] string? taskId)
public ActionResult StartTask([FromRoute, Required] string taskId)
{
var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
@@ -118,7 +118,7 @@ namespace Jellyfin.Api.Controllers
[HttpDelete("Running/{taskId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult StopTask([FromRoute, Required] string? taskId)
public ActionResult StopTask([FromRoute, Required] string taskId)
{
var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
@@ -144,7 +144,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateTask(
[FromRoute, Required] string? taskId,
[FromRoute, Required] string taskId,
[FromBody, Required] TaskTriggerInfo[] triggerInfos)
{
var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>

View File

@@ -81,7 +81,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] Guid? userId,
[FromQuery, Required] string? searchTerm,
[FromQuery, Required] string searchTerm,
[FromQuery] string? includeItemTypes,
[FromQuery] string? excludeItemTypes,
[FromQuery] string? mediaTypes,

View File

@@ -1,5 +1,3 @@
#pragma warning disable CA1801
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
@@ -7,6 +5,7 @@ using System.Linq;
using System.Threading;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
@@ -125,10 +124,10 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DisplayContent(
[FromRoute, Required] string? sessionId,
[FromQuery, Required] string? itemType,
[FromQuery, Required] string? itemId,
[FromQuery, Required] string? itemName)
[FromRoute, Required] string sessionId,
[FromQuery, Required] string itemType,
[FromQuery, Required] string itemId,
[FromQuery, Required] string itemName)
{
var command = new BrowseRequest
{
@@ -150,30 +149,26 @@ namespace Jellyfin.Api.Controllers
/// Instructs a session to play an item.
/// </summary>
/// <param name="sessionId">The session id.</param>
/// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param>
/// <param name="itemIds">The ids of the items to play, comma delimited.</param>
/// <param name="startPositionTicks">The starting position of the first item.</param>
/// <param name="playCommand">The type of play command to issue (PlayNow, PlayNext, PlayLast). Clients who have not yet implemented play next and play last may play now.</param>
/// <param name="playRequest">The <see cref="PlayRequest"/>.</param>
/// <response code="204">Instruction sent to session.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Sessions/{sessionId}/Playing")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult Play(
[FromRoute, Required] string? sessionId,
[FromQuery] Guid[] itemIds,
[FromQuery] long? startPositionTicks,
[FromQuery] PlayCommand playCommand,
[FromBody, Required] PlayRequest playRequest)
[FromRoute, Required] string sessionId,
[FromQuery, Required] PlayCommand playCommand,
[FromQuery, Required] string itemIds,
[FromQuery] long? startPositionTicks)
{
if (playRequest == null)
var playRequest = new PlayRequest
{
throw new ArgumentException("Request Body may not be null");
}
playRequest.ItemIds = itemIds;
playRequest.StartPositionTicks = startPositionTicks;
playRequest.PlayCommand = playCommand;
ItemIds = RequestHelpers.GetGuids(itemIds),
StartPositionTicks = startPositionTicks,
PlayCommand = playCommand
};
_sessionManager.SendPlayCommand(
RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id,
@@ -188,20 +183,29 @@ namespace Jellyfin.Api.Controllers
/// Issues a playstate command to a client.
/// </summary>
/// <param name="sessionId">The session id.</param>
/// <param name="playstateRequest">The <see cref="PlaystateRequest"/>.</param>
/// <param name="command">The <see cref="PlaystateCommand"/>.</param>
/// <param name="seekPositionTicks">The optional position ticks.</param>
/// <param name="controllingUserId">The optional controlling user id.</param>
/// <response code="204">Playstate command sent to session.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Sessions/{sessionId}/Playing/{command}")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SendPlaystateCommand(
[FromRoute, Required] string? sessionId,
[FromBody] PlaystateRequest playstateRequest)
[FromRoute, Required] string sessionId,
[FromRoute, Required] PlaystateCommand command,
[FromQuery] long? seekPositionTicks,
[FromQuery] string? controllingUserId)
{
_sessionManager.SendPlaystateCommand(
RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id,
sessionId,
playstateRequest,
new PlaystateRequest()
{
Command = command,
ControllingUserId = controllingUserId,
SeekPositionTicks = seekPositionTicks,
},
CancellationToken.None);
return NoContent();
@@ -218,19 +222,13 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SendSystemCommand(
[FromRoute, Required] string? sessionId,
[FromRoute, Required] string? command)
[FromRoute, Required] string sessionId,
[FromRoute, Required] GeneralCommandType command)
{
var name = command;
if (Enum.TryParse(name, true, out GeneralCommandType commandType))
{
name = commandType.ToString();
}
var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
var generalCommand = new GeneralCommand
{
Name = name,
Name = command,
ControllingUserId = currentSession.UserId
};
@@ -250,8 +248,8 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SendGeneralCommand(
[FromRoute, Required] string? sessionId,
[FromRoute, Required] string? command)
[FromRoute, Required] string sessionId,
[FromRoute, Required] GeneralCommandType command)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
@@ -277,7 +275,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SendFullGeneralCommand(
[FromRoute, Required] string? sessionId,
[FromRoute, Required] string sessionId,
[FromBody, Required] GeneralCommand command)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
@@ -311,9 +309,9 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SendMessageCommand(
[FromRoute, Required] string? sessionId,
[FromQuery, Required] string? text,
[FromQuery, Required] string? header,
[FromRoute, Required] string sessionId,
[FromQuery, Required] string text,
[FromQuery] string? header,
[FromQuery] long? timeoutMs)
{
var command = new MessageCommand
@@ -339,8 +337,8 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult AddUserToSession(
[FromRoute, Required] string? sessionId,
[FromRoute] Guid userId)
[FromRoute, Required] string sessionId,
[FromRoute, Required] Guid userId)
{
_sessionManager.AddAdditionalUser(sessionId, userId);
return NoContent();
@@ -357,8 +355,8 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult RemoveUserFromSession(
[FromRoute] string? sessionId,
[FromRoute] Guid userId)
[FromRoute, Required] string sessionId,
[FromRoute, Required] Guid userId)
{
_sessionManager.RemoveAdditionalUser(sessionId, userId);
return NoContent();
@@ -379,9 +377,9 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostCapabilities(
[FromQuery, Required] string? id,
[FromQuery] string? id,
[FromQuery] string? playableMediaTypes,
[FromQuery] string? supportedCommands,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
[FromQuery] bool supportsMediaControl = false,
[FromQuery] bool supportsSync = false,
[FromQuery] bool supportsPersistentIdentifier = true)
@@ -394,7 +392,7 @@ namespace Jellyfin.Api.Controllers
_sessionManager.ReportCapabilities(id, new ClientCapabilities
{
PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true),
SupportedCommands = RequestHelpers.Split(supportedCommands, ',', true),
SupportedCommands = supportedCommands,
SupportsMediaControl = supportsMediaControl,
SupportsSync = supportsSync,
SupportsPersistentIdentifier = supportsPersistentIdentifier
@@ -413,7 +411,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostFullCapabilities(
[FromQuery, Required] string? id,
[FromQuery] string? id,
[FromBody, Required] ClientCapabilities capabilities)
{
if (string.IsNullOrWhiteSpace(id))
@@ -438,9 +436,9 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult ReportViewing(
[FromQuery] string? sessionId,
[FromQuery] string? itemId)
[FromQuery, Required] string? itemId)
{
string session = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
string session = sessionId ?? RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
_sessionManager.ReportNowViewingItem(session, itemId);
return NoContent();
@@ -480,7 +478,7 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <response code="200">Password reset providers retrieved.</response>
/// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns>
[HttpGet("Auto/PasswordResetProviders")]
[HttpGet("Auth/PasswordResetProviders")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = Policies.RequiresElevation)]
public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders()

View File

@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
@@ -64,21 +65,16 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Sets the initial startup wizard configuration.
/// </summary>
/// <param name="uiCulture">The UI language culture.</param>
/// <param name="metadataCountryCode">The metadata country code.</param>
/// <param name="preferredMetadataLanguage">The preferred language for metadata.</param>
/// <param name="startupConfiguration">The updated startup configuration.</param>
/// <response code="204">Configuration saved.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Configuration")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateInitialConfiguration(
[FromForm] string? uiCulture,
[FromForm] string? metadataCountryCode,
[FromForm] string? preferredMetadataLanguage)
public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
{
_config.Configuration.UICulture = uiCulture;
_config.Configuration.MetadataCountryCode = metadataCountryCode;
_config.Configuration.PreferredMetadataLanguage = preferredMetadataLanguage;
_config.Configuration.UICulture = startupConfiguration.UICulture;
_config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode;
_config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage;
_config.SaveConfiguration();
return NoContent();
}
@@ -86,16 +82,15 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Sets remote access and UPnP.
/// </summary>
/// <param name="enableRemoteAccess">Enable remote access.</param>
/// <param name="enableAutomaticPortMapping">Enable UPnP.</param>
/// <param name="startupRemoteAccessDto">The startup remote access dto.</param>
/// <response code="204">Configuration saved.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("RemoteAccess")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SetRemoteAccess([FromForm] bool enableRemoteAccess, [FromForm] bool enableAutomaticPortMapping)
public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto)
{
_config.Configuration.EnableRemoteAccess = enableRemoteAccess;
_config.Configuration.EnableUPnP = enableAutomaticPortMapping;
_config.Configuration.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess;
_config.Configuration.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping;
_config.SaveConfiguration();
return NoContent();
}
@@ -131,7 +126,7 @@ namespace Jellyfin.Api.Controllers
/// </returns>
[HttpPost("User")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> UpdateStartupUser([FromForm] StartupUserDto startupUserDto)
public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
{
var user = _userManager.Users.First();

View File

@@ -1,5 +1,5 @@
using System;
using System.Linq;
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
@@ -8,6 +8,7 @@ using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -44,7 +45,6 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Gets all studios from a given item, folder, or the entire library.
/// </summary>
/// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="searchTerm">Optional. Search term.</param>
@@ -52,22 +52,10 @@ namespace Jellyfin.Api.Controllers
/// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <param name="excludeItemTypes">Optional. If specified, results will be filtered out based on item type. This allows multiple, comma delimited.</param>
/// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
/// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
/// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
/// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
/// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
/// <param name="enableUserData">Optional, include user data.</param>
/// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person ids.</param>
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
/// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
/// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
/// <param name="userId">User id.</param>
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
@@ -79,7 +67,6 @@ namespace Jellyfin.Api.Controllers
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetStudios(
[FromQuery] double? minCommunityRating,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
@@ -87,22 +74,10 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? fields,
[FromQuery] string? excludeItemTypes,
[FromQuery] string? includeItemTypes,
[FromQuery] string? filters,
[FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes,
[FromQuery] string? genres,
[FromQuery] string? genreIds,
[FromQuery] string? officialRatings,
[FromQuery] string? tags,
[FromQuery] string? years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] string? person,
[FromQuery] string? personIds,
[FromQuery] string? personTypes,
[FromQuery] string? studios,
[FromQuery] string? studioIds,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] Guid? userId,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
@@ -115,44 +90,23 @@ namespace Jellyfin.Api.Controllers
.AddClientFields(Request)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
User? user = null;
BaseItem parentItem;
User? user = userId.HasValue && userId != Guid.Empty ? _userManager.GetUserById(userId.Value) : null;
if (userId.HasValue && !userId.Equals(Guid.Empty))
{
user = _userManager.GetUserById(userId.Value);
parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
}
else
{
parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
}
var parentItem = _libraryManager.GetParentItem(parentId, userId);
var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
var query = new InternalItemsQuery(user)
{
ExcludeItemTypes = excludeItemTypesArr,
IncludeItemTypes = includeItemTypesArr,
MediaTypes = mediaTypesArr,
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
Tags = RequestHelpers.Split(tags, ',', true),
OfficialRatings = RequestHelpers.Split(officialRatings, ',', true),
Genres = RequestHelpers.Split(genres, ',', true),
GenreIds = RequestHelpers.GetGuids(genreIds),
StudioIds = RequestHelpers.GetGuids(studioIds),
Person = person,
PersonIds = RequestHelpers.GetGuids(personIds),
PersonTypes = RequestHelpers.Split(personTypes, ',', true),
Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
MinCommunityRating = minCommunityRating,
DtoOptions = dtoOptions,
SearchTerm = searchTerm,
EnableTotalRecordCount = enableTotalRecordCount
@@ -170,84 +124,9 @@ namespace Jellyfin.Api.Controllers
}
}
// Studios
if (!string.IsNullOrEmpty(studios))
{
query.StudioIds = studios.Split('|').Select(i =>
{
try
{
return _libraryManager.GetStudio(i);
}
catch
{
return null;
}
}).Where(i => i != null).Select(i => i!.Id)
.ToArray();
}
foreach (var filter in RequestHelpers.GetFilters(filters))
{
switch (filter)
{
case ItemFilter.Dislikes:
query.IsLiked = false;
break;
case ItemFilter.IsFavorite:
query.IsFavorite = true;
break;
case ItemFilter.IsFavoriteOrLikes:
query.IsFavoriteOrLiked = true;
break;
case ItemFilter.IsFolder:
query.IsFolder = true;
break;
case ItemFilter.IsNotFolder:
query.IsFolder = false;
break;
case ItemFilter.IsPlayed:
query.IsPlayed = true;
break;
case ItemFilter.IsResumable:
query.IsResumable = true;
break;
case ItemFilter.IsUnplayed:
query.IsPlayed = false;
break;
case ItemFilter.Likes:
query.IsLiked = true;
break;
}
}
var result = new QueryResult<(BaseItem, ItemCounts)>();
var dtos = result.Items.Select(i =>
{
var (baseItem, itemCounts) = i;
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
if (!string.IsNullOrWhiteSpace(includeItemTypes))
{
dto.ChildCount = itemCounts.ItemCount;
dto.ProgramCount = itemCounts.ProgramCount;
dto.SeriesCount = itemCounts.SeriesCount;
dto.EpisodeCount = itemCounts.EpisodeCount;
dto.MovieCount = itemCounts.MovieCount;
dto.TrailerCount = itemCounts.TrailerCount;
dto.AlbumCount = itemCounts.AlbumCount;
dto.SongCount = itemCounts.SongCount;
dto.ArtistCount = itemCounts.ArtistCount;
}
return dto;
});
return new QueryResult<BaseItemDto>
{
Items = dtos.ToArray(),
TotalRecordCount = result.TotalRecordCount
};
var result = _libraryManager.GetStudios(query);
var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
/// <summary>
@@ -259,7 +138,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the studio.</returns>
[HttpGet("{name}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<BaseItemDto> GetStudio([FromRoute] string name, [FromQuery] Guid? userId)
public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId)
{
var dtoOptions = new DtoOptions().AddClientFields(Request);

View File

@@ -9,7 +9,9 @@ using System.Net.Mime;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Models.SubtitleDtos;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
@@ -93,8 +95,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<Task> DeleteSubtitle(
[FromRoute] Guid itemId,
[FromRoute] int index)
[FromRoute, Required] Guid itemId,
[FromRoute, Required] int index)
{
var item = _libraryManager.GetItemById(itemId);
@@ -119,8 +121,8 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
[FromRoute] Guid itemId,
[FromRoute, Required] string? language,
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string language,
[FromQuery] bool? isPerfectMatch)
{
var video = (Video)_libraryManager.GetItemById(itemId);
@@ -139,8 +141,8 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> DownloadRemoteSubtitles(
[FromRoute] Guid itemId,
[FromRoute, Required] string? subtitleId)
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string subtitleId)
{
var video = (Video)_libraryManager.GetItemById(itemId);
@@ -169,7 +171,8 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Application.Octet)]
public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string? id)
[ProducesFile("text/*")]
public async Task<ActionResult> GetRemoteSubtitles([FromRoute, Required] string id)
{
var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
@@ -192,11 +195,12 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
[HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesFile("text/*")]
public async Task<ActionResult> GetSubtitle(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string? mediaSourceId,
[FromRoute, Required] string mediaSourceId,
[FromRoute, Required] int index,
[FromRoute, Required] string? format,
[FromRoute, Required] string format,
[FromQuery] long? endPositionTicks,
[FromQuery] bool copyTimestamps = false,
[FromQuery] bool addVttTimeMap = false,
@@ -218,8 +222,7 @@ namespace Jellyfin.Api.Controllers
var subtitleStream = mediaSource.MediaStreams
.First(i => i.Type == MediaStreamType.Subtitle && i.Index == index);
FileStream stream = new FileStream(subtitleStream.Path, FileMode.Open, FileAccess.Read);
return File(stream, MimeTypes.GetMimeType(subtitleStream.Path));
return PhysicalFile(subtitleStream.Path, MimeTypes.GetMimeType(subtitleStream.Path));
}
if (string.Equals(format, "vtt", StringComparison.OrdinalIgnoreCase) && addVttTimeMap)
@@ -258,11 +261,12 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesPlaylistFile]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> GetSubtitlePlaylist(
[FromRoute] Guid itemId,
[FromRoute] int index,
[FromRoute] string? mediaSourceId,
[FromRoute, Required] Guid itemId,
[FromRoute, Required] int index,
[FromRoute, Required] string mediaSourceId,
[FromQuery, Required] int segmentLength)
{
var item = (Video)_libraryManager.GetItemById(itemId);
@@ -285,7 +289,8 @@ namespace Jellyfin.Api.Controllers
var builder = new StringBuilder();
builder.AppendLine("#EXTM3U")
.Append("#EXT-X-TARGETDURATION:")
.AppendLine(segmentLength.ToString(CultureInfo.InvariantCulture))
.Append(segmentLength)
.AppendLine()
.AppendLine("#EXT-X-VERSION:3")
.AppendLine("#EXT-X-MEDIA-SEQUENCE:0")
.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
@@ -300,8 +305,9 @@ namespace Jellyfin.Api.Controllers
var lengthTicks = Math.Min(remaining, segmentLengthTicks);
builder.Append("#EXTINF:")
.Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds.ToString(CultureInfo.InvariantCulture))
.AppendLine(",");
.Append(TimeSpan.FromTicks(lengthTicks).TotalSeconds)
.Append(',')
.AppendLine();
var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks);
@@ -321,6 +327,33 @@ namespace Jellyfin.Api.Controllers
return File(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
}
/// <summary>
/// Upload an external subtitle file.
/// </summary>
/// <param name="itemId">The item the subtitle belongs to.</param>
/// <param name="body">The request body.</param>
/// <response code="204">Subtitle uploaded.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Videos/{itemId}/Subtitles")]
public async Task<ActionResult> UploadSubtitle(
[FromRoute, Required] Guid itemId,
[FromBody, Required] UploadSubtitleDto body)
{
var video = (Video)_libraryManager.GetItemById(itemId);
var data = Convert.FromBase64String(body.Data);
await using var memoryStream = new MemoryStream(data);
await _subtitleManager.UploadSubtitle(
video,
new SubtitleResponse
{
Format = body.Format,
Language = body.Language,
IsForced = body.IsForced,
Stream = memoryStream
}).ConfigureAwait(false);
return NoContent();
}
/// <summary>
/// Encodes a subtitle in the specified format.
/// </summary>

View File

@@ -1,5 +1,7 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Data.Enums;
@@ -8,6 +10,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -17,6 +20,7 @@ namespace Jellyfin.Api.Controllers
/// The suggestions controller.
/// </summary>
[Route("")]
[Authorize(Policy = Policies.DefaultAuthorization)]
public class SuggestionsController : BaseJellyfinApiController
{
private readonly IDtoService _dtoService;
@@ -53,7 +57,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Users/{userId}/Suggestions")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
[FromRoute] Guid userId,
[FromRoute, Required] Guid userId,
[FromQuery] string? mediaType,
[FromQuery] string? type,
[FromQuery] int? startIndex,

View File

@@ -3,10 +3,13 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
@@ -176,8 +179,8 @@ namespace Jellyfin.Api.Controllers
{
return new EndPointInfo
{
IsLocal = Request.HttpContext.Connection.LocalIpAddress.Equals(Request.HttpContext.Connection.RemoteIpAddress),
IsInNetwork = _network.IsInLocalNetwork(Request.HttpContext.Connection.RemoteIpAddress.ToString())
IsLocal = HttpContext.IsLocal(),
IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())
};
}
@@ -190,14 +193,14 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Logs/Log")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult GetLogFile([FromQuery, Required] string? name)
[ProducesFile(MediaTypeNames.Text.Plain)]
public ActionResult GetLogFile([FromQuery, Required] string name)
{
var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath)
.First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
// For older files, assume fully static
var fileShare = file.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1) ? FileShare.Read : FileShare.ReadWrite;
FileStream stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, fileShare);
return File(stream, "text/plain");
}

View File

@@ -1,6 +1,8 @@
using System;
using Jellyfin.Api.Constants;
using Jellyfin.Api.ModelBinders;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -124,7 +126,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? isHd,
[FromQuery] bool? is4K,
[FromQuery] string? locationTypes,
[FromQuery] string? excludeLocationTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired,
[FromQuery] double? minCommunityRating,
@@ -146,10 +148,10 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? parentId,
[FromQuery] string? fields,
[FromQuery] string? excludeItemTypes,
[FromQuery] string? filters,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
[FromQuery] string? mediaTypes,
[FromQuery] string? imageTypes,
[FromQuery] ImageType[] imageTypes,
[FromQuery] string? sortBy,
[FromQuery] bool? isPlayed,
[FromQuery] string? genres,
@@ -158,7 +160,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] string? person,
[FromQuery] string? personIds,
[FromQuery] string? personTypes,

View File

@@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.TV;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -69,7 +70,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("NextUp")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetNextUp(
[FromQuery, Required] Guid? userId,
[FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? fields,
@@ -77,7 +78,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? parentId,
[FromQuery] bool? enableImges,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] bool? enableUserData,
[FromQuery] bool enableTotalRecordCount = true)
{
@@ -127,14 +128,14 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Upcoming")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes(
[FromQuery, Required] Guid? userId,
[FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? fields,
[FromQuery] string? parentId,
[FromQuery] bool? enableImges,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] bool? enableUserData)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
@@ -194,8 +195,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
[FromRoute, Required] string? seriesId,
[FromQuery, Required] Guid? userId,
[FromRoute, Required] string seriesId,
[FromQuery] Guid? userId,
[FromQuery] string? fields,
[FromQuery] int? season,
[FromQuery] string? seasonId,
@@ -206,7 +207,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? limit,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] bool? enableUserData,
[FromQuery] string? sortBy)
{
@@ -317,15 +318,15 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
[FromRoute, Required] string? seriesId,
[FromQuery, Required] Guid? userId,
[FromRoute, Required] string seriesId,
[FromQuery] Guid? userId,
[FromQuery] string? fields,
[FromQuery] bool? isSpecialSeason,
[FromQuery] bool? isMissing,
[FromQuery] string? adjacentTo,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] bool? enableUserData)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)

View File

@@ -1,18 +1,24 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.VideoDtos;
using Jellyfin.Api.Models.StreamingDtos;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.MediaInfo;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Api.Controllers
{
@@ -23,27 +29,39 @@ namespace Jellyfin.Api.Controllers
public class UniversalAudioController : BaseJellyfinApiController
{
private readonly IAuthorizationContext _authorizationContext;
private readonly MediaInfoController _mediaInfoController;
private readonly DynamicHlsController _dynamicHlsController;
private readonly AudioController _audioController;
private readonly IDeviceManager _deviceManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<UniversalAudioController> _logger;
private readonly MediaInfoHelper _mediaInfoHelper;
private readonly AudioHelper _audioHelper;
private readonly DynamicHlsHelper _dynamicHlsHelper;
/// <summary>
/// Initializes a new instance of the <see cref="UniversalAudioController"/> class.
/// </summary>
/// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
/// <param name="mediaInfoController">Instance of the <see cref="MediaInfoController"/>.</param>
/// <param name="dynamicHlsController">Instance of the <see cref="DynamicHlsController"/>.</param>
/// <param name="audioController">Instance of the <see cref="AudioController"/>.</param>
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{UniversalAudioController}"/> interface.</param>
/// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param>
/// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param>
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
public UniversalAudioController(
IAuthorizationContext authorizationContext,
MediaInfoController mediaInfoController,
DynamicHlsController dynamicHlsController,
AudioController audioController)
IDeviceManager deviceManager,
ILibraryManager libraryManager,
ILogger<UniversalAudioController> logger,
MediaInfoHelper mediaInfoHelper,
AudioHelper audioHelper,
DynamicHlsHelper dynamicHlsHelper)
{
_authorizationContext = authorizationContext;
_mediaInfoController = mediaInfoController;
_dynamicHlsController = dynamicHlsController;
_audioController = audioController;
_deviceManager = deviceManager;
_libraryManager = libraryManager;
_logger = logger;
_mediaInfoHelper = mediaInfoHelper;
_audioHelper = audioHelper;
_dynamicHlsHelper = dynamicHlsHelper;
}
/// <summary>
@@ -58,6 +76,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxAudioChannels">Optional. The maximum number of audio channels.</param>
/// <param name="transcodingAudioChannels">Optional. The number of how many audio channels to transcode to.</param>
/// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
/// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
/// <param name="transcodingContainer">Optional. The container to transcode to.</param>
/// <param name="transcodingProtocol">Optional. The transcoding protocol.</param>
@@ -70,22 +89,22 @@ namespace Jellyfin.Api.Controllers
/// <response code="302">Redirected to remote audio stream.</response>
/// <returns>A <see cref="Task"/> containing the audio file.</returns>
[HttpGet("Audio/{itemId}/universal")]
[HttpGet("Audio/{itemId}/{universal=universal}.{container?}", Name = "GetUniversalAudioStream_2")]
[HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")]
[HttpHead("Audio/{itemId}/{universal=universal}.{container?}", Name = "HeadUniversalAudioStream_2")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesAudioFile]
public async Task<ActionResult> GetUniversalAudioStream(
[FromRoute] Guid itemId,
[FromRoute] string? container,
[FromRoute, Required] Guid itemId,
[FromQuery] string? container,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] Guid? userId,
[FromQuery] string? audioCodec,
[FromQuery] int? maxAudioChannels,
[FromQuery] int? transcodingAudioChannels,
[FromQuery] long? maxStreamingBitrate,
[FromQuery] int? maxStreamingBitrate,
[FromQuery] int? audioBitRate,
[FromQuery] long? startTimeTicks,
[FromQuery] string? transcodingContainer,
[FromQuery] string? transcodingProtocol,
@@ -95,24 +114,68 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool breakOnNonKeyFrames,
[FromQuery] bool enableRedirection = true)
{
bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels);
_authorizationContext.GetAuthorizationInfo(Request).DeviceId = deviceId;
var playbackInfoResult = await _mediaInfoController.GetPostedPlaybackInfo(
itemId,
userId,
maxStreamingBitrate,
startTimeTicks,
null,
null,
maxAudioChannels,
mediaSourceId,
null,
new DeviceProfileDto { DeviceProfile = deviceProfile })
.ConfigureAwait(false);
var mediaSource = playbackInfoResult.Value.MediaSources[0];
var authInfo = _authorizationContext.GetAuthorizationInfo(Request);
_logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile);
if (deviceProfile == null)
{
var clientCapabilities = _deviceManager.GetCapabilities(authInfo.DeviceId);
if (clientCapabilities != null)
{
deviceProfile = clientCapabilities.DeviceProfile;
}
}
var info = await _mediaInfoHelper.GetPlaybackInfo(
itemId,
userId,
mediaSourceId)
.ConfigureAwait(false);
if (deviceProfile != null)
{
// set device specific data
var item = _libraryManager.GetItemById(itemId);
foreach (var sourceInfo in info.MediaSources)
{
_mediaInfoHelper.SetDeviceSpecificData(
item,
sourceInfo,
deviceProfile,
authInfo,
maxStreamingBitrate ?? deviceProfile.MaxStreamingBitrate,
startTimeTicks ?? 0,
mediaSourceId ?? string.Empty,
null,
null,
maxAudioChannels,
info!.PlaySessionId!,
userId ?? Guid.Empty,
true,
true,
true,
true,
true,
Request.HttpContext.GetNormalizedRemoteIp());
}
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
}
if (info.MediaSources != null)
{
foreach (var source in info.MediaSources)
{
_mediaInfoHelper.NormalizeMediaSourceContainer(source, deviceProfile!, DlnaProfileType.Video);
}
}
var mediaSource = info.MediaSources![0];
if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http)
{
if (enableRedirection)
@@ -127,129 +190,71 @@ namespace Jellyfin.Api.Controllers
var isStatic = mediaSource.SupportsDirectStream;
if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
{
var transcodingProfile = deviceProfile.TranscodingProfiles[0];
// hls segment container can only be mpegts or fmp4 per ffmpeg documentation
// TODO: remove this when we switch back to the segment muxer
var supportedHlsContainers = new[] { "mpegts", "fmp4" };
if (isHeadRequest)
var dynamicHlsRequestDto = new HlsAudioRequestDto
{
_dynamicHlsController.Request.Method = HttpMethod.Head.Method;
}
return await _dynamicHlsController.GetMasterHlsAudioPlaylist(
itemId,
".m3u8",
isStatic,
null,
null,
null,
playbackInfoResult.Value.PlaySessionId,
Id = itemId,
Container = ".m3u8",
Static = isStatic,
PlaySessionId = info.PlaySessionId,
// fallback to mpegts if device reports some weird value unsupported by hls
Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "mpegts",
null,
null,
mediaSource.Id,
deviceId,
transcodingProfile.AudioCodec,
null,
null,
null,
transcodingProfile.BreakOnNonKeyFrames,
maxAudioSampleRate,
maxAudioBitDepth,
null,
isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)),
maxAudioChannels,
null,
null,
null,
null,
null,
startTimeTicks,
null,
null,
null,
null,
SubtitleDeliveryMethod.Hls,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()),
null,
null,
EncodingContext.Static,
new Dictionary<string, string>())
.ConfigureAwait(false);
}
else
{
if (isHeadRequest)
{
_audioController.Request.Method = HttpMethod.Head.Method;
}
SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "mpegts",
MediaSourceId = mediaSourceId,
DeviceId = deviceId,
AudioCodec = audioCodec,
EnableAutoStreamCopy = true,
AllowAudioStreamCopy = true,
AllowVideoStreamCopy = true,
BreakOnNonKeyFrames = breakOnNonKeyFrames,
AudioSampleRate = maxAudioSampleRate,
MaxAudioChannels = maxAudioChannels,
MaxAudioBitDepth = maxAudioBitDepth,
AudioBitRate = audioBitRate ?? maxStreamingBitrate,
StartTimeTicks = startTimeTicks,
SubtitleMethod = SubtitleDeliveryMethod.Hls,
RequireAvc = true,
DeInterlace = true,
RequireNonAnamorphic = true,
EnableMpegtsM2TsMode = true,
TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()),
Context = EncodingContext.Static,
StreamOptions = new Dictionary<string, string>(),
EnableAdaptiveBitrateStreaming = true
};
return await _audioController.GetAudioStream(
itemId,
isStatic ? null : ("." + mediaSource.TranscodingContainer),
isStatic,
null,
null,
null,
playbackInfoResult.Value.PlaySessionId,
null,
null,
null,
mediaSource.Id,
deviceId,
audioCodec,
null,
null,
null,
breakOnNonKeyFrames,
maxAudioSampleRate,
maxAudioBitDepth,
isStatic ? (int?)null : Convert.ToInt32(Math.Min(maxStreamingBitrate ?? 192000, int.MaxValue)),
null,
maxAudioChannels,
null,
null,
null,
null,
null,
startTimeTicks,
null,
null,
null,
null,
SubtitleDeliveryMethod.Embed,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()),
null,
null,
null,
null)
return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true)
.ConfigureAwait(false);
}
var audioStreamingDto = new StreamingRequestDto
{
Id = itemId,
Container = isStatic ? null : ("." + mediaSource.TranscodingContainer),
Static = isStatic,
PlaySessionId = info.PlaySessionId,
MediaSourceId = mediaSourceId,
DeviceId = deviceId,
AudioCodec = audioCodec,
EnableAutoStreamCopy = true,
AllowAudioStreamCopy = true,
AllowVideoStreamCopy = true,
BreakOnNonKeyFrames = breakOnNonKeyFrames,
AudioSampleRate = maxAudioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = isStatic ? (int?)null : (audioBitRate ?? maxStreamingBitrate),
MaxAudioBitDepth = maxAudioBitDepth,
AudioChannels = maxAudioChannels,
CopyTimestamps = true,
StartTimeTicks = startTimeTicks,
SubtitleMethod = SubtitleDeliveryMethod.Embed,
TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()),
Context = EncodingContext.Static
};
return await _audioHelper.GetAudioStream(TranscodingJobType.Progressive, audioStreamingDto).ConfigureAwait(false);
}
private DeviceProfile GetDeviceProfile(
@@ -265,20 +270,24 @@ namespace Jellyfin.Api.Controllers
{
var deviceProfile = new DeviceProfile();
var directPlayProfiles = new List<DirectPlayProfile>();
var containers = RequestHelpers.Split(container, ',', true);
foreach (var cont in containers)
int len = containers.Length;
var directPlayProfiles = new DirectPlayProfile[len];
for (int i = 0; i < len; i++)
{
var parts = RequestHelpers.Split(cont, ',', true);
var parts = RequestHelpers.Split(containers[i], '|', true);
var audioCodecs = parts.Length == 1 ? null : string.Join(",", parts.Skip(1).ToArray());
var audioCodecs = parts.Length == 1 ? null : string.Join(',', parts.Skip(1));
directPlayProfiles.Add(new DirectPlayProfile { Type = DlnaProfileType.Audio, Container = parts[0], AudioCodec = audioCodecs });
directPlayProfiles[i] = new DirectPlayProfile
{
Type = DlnaProfileType.Audio,
Container = parts[0],
AudioCodec = audioCodecs
};
}
deviceProfile.DirectPlayProfiles = directPlayProfiles.ToArray();
deviceProfile.DirectPlayProfiles = directPlayProfiles;
deviceProfile.TranscodingProfiles = new[]
{

View File

@@ -7,6 +7,7 @@ using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.UserDtos;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
@@ -108,7 +109,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.IgnoreParentalControl)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<UserDto> GetUserById([FromRoute] Guid userId)
public ActionResult<UserDto> GetUserById([FromRoute, Required] Guid userId)
{
var user = _userManager.GetUserById(userId);
@@ -117,7 +118,7 @@ namespace Jellyfin.Api.Controllers
return NotFound("User not found");
}
var result = _userManager.GetUserDto(user, HttpContext.Connection.RemoteIpAddress.ToString());
var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp());
return result;
}
@@ -125,14 +126,14 @@ namespace Jellyfin.Api.Controllers
/// Deletes a user.
/// </summary>
/// <param name="userId">The user id.</param>
/// <response code="200">User deleted.</response>
/// <response code="204">User deleted.</response>
/// <response code="404">User not found.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="NotFoundResult"/> if the user was not found.</returns>
[HttpDelete("{userId}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DeleteUser([FromRoute] Guid userId)
public ActionResult DeleteUser([FromRoute, Required] Guid userId)
{
var user = _userManager.GetUserById(userId);
_sessionManager.RevokeUserTokens(user.Id, null);
@@ -156,7 +157,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
[FromRoute, Required] Guid userId,
[FromQuery, Required] string? pw,
[FromQuery, Required] string pw,
[FromQuery] string? password)
{
var user = _userManager.GetUserById(userId);
@@ -203,7 +204,7 @@ namespace Jellyfin.Api.Controllers
DeviceName = auth.Device,
Password = request.Pw,
PasswordSha1 = request.Password,
RemoteEndPoint = HttpContext.Connection.RemoteIpAddress.ToString(),
RemoteEndPoint = HttpContext.GetNormalizedRemoteIp(),
Username = request.Username
}).ConfigureAwait(false);
@@ -212,7 +213,41 @@ namespace Jellyfin.Api.Controllers
catch (SecurityException e)
{
// rethrow adding IP address to message
throw new SecurityException($"[{HttpContext.Connection.RemoteIpAddress}] {e.Message}", e);
throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e);
}
}
/// <summary>
/// Authenticates a user with quick connect.
/// </summary>
/// <param name="request">The <see cref="QuickConnectDto"/> request.</param>
/// <response code="200">User authenticated.</response>
/// <response code="400">Missing token.</response>
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
[HttpPost("AuthenticateWithQuickConnect")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<AuthenticationResult>> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
{
var auth = _authContext.GetAuthorizationInfo(Request);
try
{
var authRequest = new AuthenticationRequest
{
App = auth.Client,
AppVersion = auth.Version,
DeviceId = auth.DeviceId,
DeviceName = auth.Device,
};
return await _sessionManager.AuthenticateQuickConnect(
authRequest,
request.Token).ConfigureAwait(false);
}
catch (SecurityException e)
{
// rethrow adding IP address to message
throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e);
}
}
@@ -221,7 +256,7 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="request">The <see cref="UpdateUserPassword"/> request.</param>
/// <response code="200">Password successfully reset.</response>
/// <response code="204">Password successfully reset.</response>
/// <response code="403">User is not allowed to update the password.</response>
/// <response code="404">User not found.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
@@ -231,7 +266,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdateUserPassword(
[FromRoute] Guid userId,
[FromRoute, Required] Guid userId,
[FromBody] UpdateUserPassword request)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
@@ -256,7 +291,7 @@ namespace Jellyfin.Api.Controllers
user.Username,
request.CurrentPw,
request.CurrentPw,
HttpContext.Connection.RemoteIpAddress.ToString(),
HttpContext.GetNormalizedRemoteIp(),
false).ConfigureAwait(false);
if (success == null)
@@ -279,7 +314,7 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param>
/// <response code="200">Password successfully reset.</response>
/// <response code="204">Password successfully reset.</response>
/// <response code="403">User is not allowed to update the password.</response>
/// <response code="404">User not found.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
@@ -289,7 +324,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateUserEasyPassword(
[FromRoute] Guid userId,
[FromRoute, Required] Guid userId,
[FromBody] UpdateUserEasyPassword request)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
@@ -331,7 +366,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> UpdateUser(
[FromRoute] Guid userId,
[FromRoute, Required] Guid userId,
[FromBody] UserDto updateUser)
{
if (updateUser == null)
@@ -346,17 +381,13 @@ namespace Jellyfin.Api.Controllers
var user = _userManager.GetUserById(userId);
if (string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
{
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
_userManager.UpdateConfiguration(user.Id, updateUser.Configuration);
}
else
if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
{
await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
_userManager.UpdateConfiguration(updateUser.Id, updateUser.Configuration);
}
await _userManager.UpdateConfigurationAsync(user.Id, updateUser.Configuration).ConfigureAwait(false);
return NoContent();
}
@@ -374,8 +405,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult UpdateUserPolicy(
[FromRoute] Guid userId,
public async Task<ActionResult> UpdateUserPolicy(
[FromRoute, Required] Guid userId,
[FromBody] UserPolicy newPolicy)
{
if (newPolicy == null)
@@ -412,7 +443,7 @@ namespace Jellyfin.Api.Controllers
_sessionManager.RevokeUserTokens(user.Id, currentToken);
}
_userManager.UpdatePolicy(userId, newPolicy);
await _userManager.UpdatePolicyAsync(userId, newPolicy).ConfigureAwait(false);
return NoContent();
}
@@ -429,8 +460,8 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public ActionResult UpdateUserConfiguration(
[FromRoute] Guid userId,
public async Task<ActionResult> UpdateUserConfiguration(
[FromRoute, Required] Guid userId,
[FromBody] UserConfiguration userConfig)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, false))
@@ -438,7 +469,7 @@ namespace Jellyfin.Api.Controllers
return Forbid("User configuration update not allowed");
}
_userManager.UpdateConfiguration(userId, userConfig);
await _userManager.UpdateConfigurationAsync(userId, userConfig).ConfigureAwait(false);
return NoContent();
}
@@ -462,7 +493,7 @@ namespace Jellyfin.Api.Controllers
await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false);
}
var result = _userManager.GetUserDto(newUser, HttpContext.Connection.RemoteIpAddress.ToString());
var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp());
return result;
}
@@ -470,17 +501,17 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Initiates the forgot password process for a local user.
/// </summary>
/// <param name="enteredUsername">The entered username.</param>
/// <param name="forgotPasswordRequest">The forgot password request containing the entered username.</param>
/// <response code="200">Password reset process started.</response>
/// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns>
[HttpPost("ForgotPassword")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody] string? enteredUsername)
public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest)
{
var isLocal = HttpContext.Connection.RemoteIpAddress.Equals(HttpContext.Connection.LocalIpAddress)
|| _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString());
var isLocal = HttpContext.IsLocal()
|| _networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp());
var result = await _userManager.StartForgotPasswordProcess(enteredUsername, isLocal).ConfigureAwait(false);
var result = await _userManager.StartForgotPasswordProcess(forgotPasswordRequest.EnteredUsername, isLocal).ConfigureAwait(false);
return result;
}
@@ -499,6 +530,33 @@ namespace Jellyfin.Api.Controllers
return result;
}
/// <summary>
/// Gets the user based on auth token.
/// </summary>
/// <response code="200">User returned.</response>
/// <response code="400">Token is not owned by a user.</response>
/// <returns>A <see cref="UserDto"/> for the authenticated user.</returns>
[HttpGet("Me")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public ActionResult<UserDto> GetCurrentUser()
{
var userId = ClaimHelpers.GetUserId(Request.HttpContext.User);
if (userId == null)
{
return BadRequest();
}
var user = _userManager.GetUserById(userId.Value);
if (user == null)
{
return BadRequest();
}
return _userManager.GetUserDto(user);
}
private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
{
var users = _userManager.Users;
@@ -525,7 +583,7 @@ namespace Jellyfin.Api.Controllers
if (filterByNetwork)
{
if (!_networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString()))
if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()))
{
users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess));
}
@@ -533,7 +591,7 @@ namespace Jellyfin.Api.Controllers
var result = users
.OrderBy(u => u.Username)
.Select(i => _userManager.GetUserDto(i, HttpContext.Connection.RemoteIpAddress.ToString()));
.Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp()));
return result;
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -70,7 +71,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the d item.</returns>
[HttpGet("Users/{userId}/Items/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
{
var user = _userManager.GetUserById(userId);
@@ -93,7 +94,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
[HttpGet("Users/{userId}/Items/Root")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<BaseItemDto> GetRootFolder([FromRoute] Guid userId)
public ActionResult<BaseItemDto> GetRootFolder([FromRoute, Required] Guid userId)
{
var user = _userManager.GetUserById(userId);
var item = _libraryManager.GetUserRootFolder();
@@ -110,7 +111,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
[HttpGet("Users/{userId}/Items/{itemId}/Intros")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute] Guid userId, [FromRoute] Guid itemId)
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
{
var user = _userManager.GetUserById(userId);
@@ -138,7 +139,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
[HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
{
return MarkFavorite(userId, itemId, true);
}
@@ -152,7 +153,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
[HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
{
return MarkFavorite(userId, itemId, false);
}
@@ -166,7 +167,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
[HttpDelete("Users/{userId}/Items/{itemId}/Rating")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute] Guid userId, [FromRoute] Guid itemId)
public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
{
return UpdateUserItemRatingInternal(userId, itemId, null);
}
@@ -181,7 +182,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
[HttpPost("Users/{userId}/Items/{itemId}/Rating")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute] Guid userId, [FromRoute] Guid itemId, [FromQuery] bool? likes)
public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes)
{
return UpdateUserItemRatingInternal(userId, itemId, likes);
}
@@ -195,7 +196,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>The items local trailers.</returns>
[HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute] Guid userId, [FromRoute] Guid itemId)
public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
{
var user = _userManager.GetUserById(userId);
@@ -230,7 +231,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="OkResult"/> containing the special features.</returns>
[HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute] Guid userId, [FromRoute] Guid itemId)
public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId)
{
var user = _userManager.GetUserById(userId);
@@ -264,14 +265,14 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Users/{userId}/Items/Latest")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
[FromRoute] Guid userId,
[FromRoute, Required] Guid userId,
[FromQuery] Guid? parentId,
[FromQuery] string? fields,
[FromQuery] string? includeItemTypes,
[FromQuery] bool? isPlayed,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] bool? enableUserData,
[FromQuery] int limit = 20,
[FromQuery] bool groupItems = true)

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using Jellyfin.Api.Extensions;
@@ -64,7 +65,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Users/{userId}/Views")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetUserViews(
[FromRoute] Guid userId,
[FromRoute, Required] Guid userId,
[FromQuery] bool? includeExternalContent,
[FromQuery] string? presetViews,
[FromQuery] bool includeHidden = false)
@@ -126,7 +127,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Users/{userId}/GroupingOptions")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute] Guid userId)
public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute, Required] Guid userId)
{
var user = _userManager.GetUserById(userId);
if (user == null)

View File

@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.PlaybackDtos;
@@ -161,8 +163,9 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="FileResult"/> containing the hls file.</returns>
[HttpGet("Videos/{itemId}/live.m3u8")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesPlaylistFile]
public async Task<ActionResult> GetLiveHlsStream(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromQuery] string? container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
@@ -368,7 +371,7 @@ namespace Jellyfin.Api.Controllers
var baseUrlParam = string.Format(
CultureInfo.InvariantCulture,
"\"hls{0}\"",
"\"hls/{0}/\"",
Path.GetFileNameWithoutExtension(outputPath));
return string.Format(

View File

@@ -6,11 +6,13 @@ using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.StreamingDtos;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Dlna;
@@ -114,7 +116,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("{itemId}/AdditionalParts")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute] Guid itemId, [FromQuery] Guid? userId)
public ActionResult<QueryResult<BaseItemDto>> GetAdditionalPart([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
@@ -159,9 +161,9 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="NotFoundResult"/> if the video doesn't exist.</returns>
[HttpDelete("{itemId}/AlternateSources")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DeleteAlternateSources([FromRoute] Guid itemId)
public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId)
{
var video = (Video)_libraryManager.GetItemById(itemId);
@@ -180,12 +182,12 @@ namespace Jellyfin.Api.Controllers
link.SetPrimaryVersionId(null);
link.LinkedAlternateVersions = Array.Empty<LinkedChild>();
link.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
video.LinkedAlternateVersions = Array.Empty<LinkedChild>();
video.SetPrimaryVersionId(null);
video.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
return NoContent();
}
@@ -201,7 +203,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public ActionResult MergeVersions([FromQuery, Required] string? itemIds)
public async Task<ActionResult> MergeVersions([FromQuery, Required] string itemIds)
{
var items = RequestHelpers.Split(itemIds, ',', true)
.Select(i => _libraryManager.GetItemById(i))
@@ -233,37 +235,40 @@ namespace Jellyfin.Api.Controllers
.First();
}
var list = primaryVersion.LinkedAlternateVersions.ToList();
var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList();
foreach (var item in items.Where(i => i.Id != primaryVersion.Id))
{
item.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture));
item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
list.Add(new LinkedChild
if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, item.Path, StringComparison.OrdinalIgnoreCase)))
{
Path = item.Path,
ItemId = item.Id
});
alternateVersionsOfPrimary.Add(new LinkedChild
{
Path = item.Path,
ItemId = item.Id
});
}
foreach (var linkedItem in item.LinkedAlternateVersions)
{
if (!list.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase)))
if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase)))
{
list.Add(linkedItem);
alternateVersionsOfPrimary.Add(linkedItem);
}
}
if (item.LinkedAlternateVersions.Length > 0)
{
item.LinkedAlternateVersions = Array.Empty<LinkedChild>();
item.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
}
primaryVersion.LinkedAlternateVersions = list.ToArray();
primaryVersion.UpdateToRepository(ItemUpdateType.MetadataEdit, CancellationToken.None);
primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray();
await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
return NoContent();
}
@@ -321,13 +326,14 @@ namespace Jellyfin.Api.Controllers
/// <param name="streamOptions">Optional. The streaming options.</param>
/// <response code="200">Video stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("{itemId}/{stream=stream}.{container?}", Name = "GetVideoStream_2")]
[HttpGet("{itemId}/{stream=stream}.{container?}", Name = "GetVideoStreamWithExt")]
[HttpGet("{itemId}/stream")]
[HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStream_2")]
[HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStreamWithExt")]
[HttpHead("{itemId}/stream", Name = "HeadVideoStream")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesVideoFile]
public async Task<ActionResult> GetVideoStream(
[FromRoute] Guid itemId,
[FromRoute, Required] Guid itemId,
[FromRoute] string? container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
@@ -470,8 +476,8 @@ namespace Jellyfin.Api.Controllers
{
StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, startTimeTicks, Request, _dlnaManager);
var httpClient = _httpClientFactory.CreateClient();
return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, this, httpClient).ConfigureAwait(false);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
return await FileStreamResponseHelpers.GetStaticRemoteStreamResult(state, isHeadRequest, httpClient, HttpContext).ConfigureAwait(false);
}
if (@static.HasValue && @static.Value && state.InputProtocol != MediaProtocol.File)
@@ -507,7 +513,7 @@ namespace Jellyfin.Api.Controllers
state.MediaPath,
contentType,
isHeadRequest,
this);
HttpContext);
}
// Need to start ffmpeg (because media can't be returned directly)
@@ -517,10 +523,9 @@ namespace Jellyfin.Api.Controllers
return await FileStreamResponseHelpers.GetTranscodedFile(
state,
isHeadRequest,
this,
HttpContext,
_transcodingJobHelper,
ffmpegCommandLineArguments,
Request,
_transcodingJobType,
cancellationTokenSource).ConfigureAwait(false);
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
@@ -9,6 +10,7 @@ using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -76,7 +78,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? sortBy,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] ImageType[] enableImageTypes,
[FromQuery] Guid? userId,
[FromQuery] bool recursive = true,
[FromQuery] bool? enableImages = true)
@@ -179,7 +181,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("{year}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<BaseItemDto> GetYear([FromRoute] int year, [FromQuery] Guid? userId)
public ActionResult<BaseItemDto> GetYear([FromRoute, Required] int year, [FromQuery] Guid? userId)
{
var item = _libraryManager.GetYear(year);
if (item == null)