aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Api/Controllers
diff options
context:
space:
mode:
authorcrobibero <cody@robibe.ro>2020-07-21 08:03:09 -0600
committercrobibero <cody@robibe.ro>2020-07-21 08:03:09 -0600
commitb040d89e5cda3d49ecb45e5441053dc61872f272 (patch)
tree81455a2f97d10c06c20a491a5c46e75fbac1fb47 /Jellyfin.Api/Controllers
parent0740ec611211cb121a2ea4f97ab43b92d6411d4d (diff)
parent5b57c81ee14ce585161b9ac331e6e3528826b815 (diff)
Merge remote-tracking branch 'upstream/api-migration' into api-image-service
Diffstat (limited to 'Jellyfin.Api/Controllers')
-rw-r--r--Jellyfin.Api/Controllers/ActivityLogController.cs10
-rw-r--r--Jellyfin.Api/Controllers/AlbumsController.cs8
-rw-r--r--Jellyfin.Api/Controllers/ApiKeyController.cs4
-rw-r--r--Jellyfin.Api/Controllers/ArtistsController.cs488
-rw-r--r--Jellyfin.Api/Controllers/ChannelsController.cs256
-rw-r--r--Jellyfin.Api/Controllers/CollectionController.cs14
-rw-r--r--Jellyfin.Api/Controllers/ConfigurationController.cs4
-rw-r--r--Jellyfin.Api/Controllers/DashboardController.cs3
-rw-r--r--Jellyfin.Api/Controllers/DevicesController.cs8
-rw-r--r--Jellyfin.Api/Controllers/DisplayPreferencesController.cs12
-rw-r--r--Jellyfin.Api/Controllers/EnvironmentController.cs191
-rw-r--r--Jellyfin.Api/Controllers/FilterController.cs13
-rw-r--r--Jellyfin.Api/Controllers/GenresController.cs320
-rw-r--r--Jellyfin.Api/Controllers/ImageByNameController.cs12
-rw-r--r--Jellyfin.Api/Controllers/InstantMixController.cs328
-rw-r--r--Jellyfin.Api/Controllers/ItemRefreshController.cs1
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs592
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs71
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs43
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs1236
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs775
-rw-r--r--Jellyfin.Api/Controllers/MoviesController.cs329
-rw-r--r--Jellyfin.Api/Controllers/MusicGenresController.cs313
-rw-r--r--Jellyfin.Api/Controllers/NotificationsController.cs5
-rw-r--r--Jellyfin.Api/Controllers/PackageController.cs46
-rw-r--r--Jellyfin.Api/Controllers/PersonsController.cs282
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs18
-rw-r--r--Jellyfin.Api/Controllers/PlaystateController.cs372
-rw-r--r--Jellyfin.Api/Controllers/PluginsController.cs5
-rw-r--r--Jellyfin.Api/Controllers/RemoteImageController.cs4
-rw-r--r--Jellyfin.Api/Controllers/ScheduledTasksController.cs161
-rw-r--r--Jellyfin.Api/Controllers/SearchController.cs14
-rw-r--r--Jellyfin.Api/Controllers/SessionController.cs58
-rw-r--r--Jellyfin.Api/Controllers/StartupController.cs7
-rw-r--r--Jellyfin.Api/Controllers/StudiosController.cs277
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs18
-rw-r--r--Jellyfin.Api/Controllers/SuggestionsController.cs6
-rw-r--r--Jellyfin.Api/Controllers/SystemController.cs2
-rw-r--r--Jellyfin.Api/Controllers/TrailersController.cs307
-rw-r--r--Jellyfin.Api/Controllers/TvShowsController.cs35
-rw-r--r--Jellyfin.Api/Controllers/UserController.cs17
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs391
-rw-r--r--Jellyfin.Api/Controllers/UserViewsController.cs148
-rw-r--r--Jellyfin.Api/Controllers/VideoAttachmentsController.cs2
-rw-r--r--Jellyfin.Api/Controllers/VideosController.cs8
-rw-r--r--Jellyfin.Api/Controllers/YearsController.cs231
47 files changed, 7240 insertions, 207 deletions
diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs
index c287d1a77..12ea24973 100644
--- a/Jellyfin.Api/Controllers/ActivityLogController.cs
+++ b/Jellyfin.Api/Controllers/ActivityLogController.cs
@@ -1,5 +1,4 @@
using System;
-using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Data.Entities;
@@ -35,6 +34,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="minDate">Optional. The minimum date. Format = ISO.</param>
+ /// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param>
/// <response code="200">Activity log returned.</response>
/// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
[HttpGet("Entries")]
@@ -42,10 +42,14 @@ namespace Jellyfin.Api.Controllers
public ActionResult<QueryResult<ActivityLogEntry>> GetLogEntries(
[FromQuery] int? startIndex,
[FromQuery] int? limit,
- [FromQuery] DateTime? minDate)
+ [FromQuery] DateTime? minDate,
+ [FromQuery] bool? hasUserId)
{
var filterFunc = new Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>>(
- entries => entries.Where(entry => entry.DateCreated >= minDate));
+ 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);
}
diff --git a/Jellyfin.Api/Controllers/AlbumsController.cs b/Jellyfin.Api/Controllers/AlbumsController.cs
index 622123873..01ba7fc32 100644
--- a/Jellyfin.Api/Controllers/AlbumsController.cs
+++ b/Jellyfin.Api/Controllers/AlbumsController.cs
@@ -52,8 +52,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSimilarAlbums(
[FromRoute] string albumId,
- [FromQuery] Guid userId,
- [FromQuery] string excludeArtistIds,
+ [FromQuery] Guid? userId,
+ [FromQuery] string? excludeArtistIds,
[FromQuery] int? limit)
{
var dtoOptions = new DtoOptions().AddClientFields(Request);
@@ -84,8 +84,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSimilarArtists(
[FromRoute] string artistId,
- [FromQuery] Guid userId,
- [FromQuery] string excludeArtistIds,
+ [FromQuery] Guid? userId,
+ [FromQuery] string? excludeArtistIds,
[FromQuery] int? limit)
{
var dtoOptions = new DtoOptions().AddClientFields(Request);
diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs
index ed521c1fc..fef4d7262 100644
--- a/Jellyfin.Api/Controllers/ApiKeyController.cs
+++ b/Jellyfin.Api/Controllers/ApiKeyController.cs
@@ -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] string key)
+ public ActionResult RevokeKey([FromRoute] string? key)
{
_sessionManager.RevokeToken(key);
return NoContent();
diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs
new file mode 100644
index 000000000..d39021446
--- /dev/null
+++ b/Jellyfin.Api/Controllers/ArtistsController.cs
@@ -0,0 +1,488 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+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;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// The artists controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [Route("/Artists")]
+ public class ArtistsController : BaseJellyfinApiController
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly IDtoService _dtoService;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ArtistsController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ public ArtistsController(
+ ILibraryManager libraryManager,
+ IUserManager userManager,
+ IDtoService dtoService)
+ {
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _dtoService = dtoService;
+ }
+
+ /// <summary>
+ /// Gets all artists from a given item, folder, or the entire library.
+ /// </summary>
+ /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="searchTerm">Optional. Search term.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. 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>
+ /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+ /// <param name="enableImages">Optional, include image information in output.</param>
+ /// <param name="enableTotalRecordCount">Total record count.</param>
+ /// <response code="200">Artists returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the artists.</returns>
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetArtists(
+ [FromQuery] double? minCommunityRating,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] string? searchTerm,
+ [FromQuery] string? parentId,
+ [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] Guid? userId,
+ [FromQuery] string? nameStartsWithOrGreater,
+ [FromQuery] string? nameStartsWith,
+ [FromQuery] string? nameLessThan,
+ [FromQuery] bool? enableImages = true,
+ [FromQuery] bool enableTotalRecordCount = true)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .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 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
+ };
+
+ 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 = _libraryManager.GetArtists(query);
+
+ 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
+ };
+ }
+
+ /// <summary>
+ /// Gets all album artists from a given item, folder, or the entire library.
+ /// </summary>
+ /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="searchTerm">Optional. Search term.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. 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>
+ /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+ /// <param name="enableImages">Optional, include image information in output.</param>
+ /// <param name="enableTotalRecordCount">Total record count.</param>
+ /// <response code="200">Album artists returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the album artists.</returns>
+ [HttpGet("AlbumArtists")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists(
+ [FromQuery] double? minCommunityRating,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] string? searchTerm,
+ [FromQuery] string? parentId,
+ [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] Guid? userId,
+ [FromQuery] string? nameStartsWithOrGreater,
+ [FromQuery] string? nameStartsWith,
+ [FromQuery] string? nameLessThan,
+ [FromQuery] bool? enableImages = true,
+ [FromQuery] bool enableTotalRecordCount = true)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .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 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
+ };
+
+ 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 = _libraryManager.GetAlbumArtists(query);
+
+ 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
+ };
+ }
+
+ /// <summary>
+ /// Gets an artist by name.
+ /// </summary>
+ /// <param name="name">Studio name.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <response code="200">Artist returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the artist.</returns>
+ [HttpGet("{name}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<BaseItemDto> GetArtistByName([FromRoute] string name, [FromQuery] Guid? userId)
+ {
+ var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+ var item = _libraryManager.GetArtist(name, dtoOptions);
+
+ if (userId.HasValue && !userId.Equals(Guid.Empty))
+ {
+ var user = _userManager.GetUserById(userId.Value);
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+ }
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs
new file mode 100644
index 000000000..bdd7dfd96
--- /dev/null
+++ b/Jellyfin.Api/Controllers/ChannelsController.cs
@@ -0,0 +1,256 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Channels;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Channels Controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class ChannelsController : BaseJellyfinApiController
+ {
+ private readonly IChannelManager _channelManager;
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ChannelsController"/> class.
+ /// </summary>
+ /// <param name="channelManager">Instance of the <see cref="IChannelManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ public ChannelsController(IChannelManager channelManager, IUserManager userManager)
+ {
+ _channelManager = channelManager;
+ _userManager = userManager;
+ }
+
+ /// <summary>
+ /// Gets available channels.
+ /// </summary>
+ /// <param name="userId">User Id to filter by. Use <see cref="Guid.Empty"/> to not filter by user.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="supportsLatestItems">Optional. Filter by channels that support getting latest items.</param>
+ /// <param name="supportsMediaDeletion">Optional. Filter by channels that support media deletion.</param>
+ /// <param name="isFavorite">Optional. Filter by channels that are favorite.</param>
+ /// <response code="200">Channels returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the channels.</returns>
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetChannels(
+ [FromQuery] Guid? userId,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] bool? supportsLatestItems,
+ [FromQuery] bool? supportsMediaDeletion,
+ [FromQuery] bool? isFavorite)
+ {
+ return _channelManager.GetChannels(new ChannelQuery
+ {
+ Limit = limit,
+ StartIndex = startIndex,
+ UserId = userId ?? Guid.Empty,
+ SupportsLatestItems = supportsLatestItems,
+ SupportsMediaDeletion = supportsMediaDeletion,
+ IsFavorite = isFavorite
+ });
+ }
+
+ /// <summary>
+ /// Get all channel features.
+ /// </summary>
+ /// <response code="200">All channel features returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
+ [HttpGet("Features")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<ChannelFeatures>> GetAllChannelFeatures()
+ {
+ return _channelManager.GetAllChannelFeatures();
+ }
+
+ /// <summary>
+ /// Get channel features.
+ /// </summary>
+ /// <param name="channelId">Channel id.</param>
+ /// <response code="200">Channel features returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
+ [HttpGet("{channelId}/Features")]
+ public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute] string channelId)
+ {
+ return _channelManager.GetChannelFeatures(channelId);
+ }
+
+ /// <summary>
+ /// Get channel items.
+ /// </summary>
+ /// <param name="channelId">Channel Id.</param>
+ /// <param name="folderId">Optional. Folder Id.</param>
+ /// <param name="userId">Optional. User Id.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="sortOrder">Optional. Sort Order - Ascending,Descending.</param>
+ /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</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>
+ /// <returns>
+ /// A <see cref="Task"/> representing the request to get the channel items.
+ /// The task result contains an <see cref="OkResult"/> containing the channel items.
+ /// </returns>
+ [HttpGet("{channelId}/Items")]
+ public async Task<ActionResult<QueryResult<BaseItemDto>>> GetChannelItems(
+ [FromRoute] Guid channelId,
+ [FromQuery] Guid? folderId,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] string? sortOrder,
+ [FromQuery] string? filters,
+ [FromQuery] string? sortBy,
+ [FromQuery] string? fields)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+
+ var query = new InternalItemsQuery(user)
+ {
+ Limit = limit,
+ StartIndex = startIndex,
+ ChannelIds = new[] { channelId },
+ ParentId = folderId ?? Guid.Empty,
+ OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+ DtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ };
+
+ foreach (var filter in RequestHelpers.GetFilters(filters))
+ {
+ switch (filter)
+ {
+ case ItemFilter.IsFolder:
+ query.IsFolder = true;
+ break;
+ case ItemFilter.IsNotFolder:
+ query.IsFolder = false;
+ break;
+ case ItemFilter.IsUnplayed:
+ query.IsPlayed = false;
+ break;
+ case ItemFilter.IsPlayed:
+ query.IsPlayed = true;
+ break;
+ case ItemFilter.IsFavorite:
+ query.IsFavorite = true;
+ break;
+ case ItemFilter.IsResumable:
+ query.IsResumable = true;
+ break;
+ case ItemFilter.Likes:
+ query.IsLiked = true;
+ break;
+ case ItemFilter.Dislikes:
+ query.IsLiked = false;
+ break;
+ case ItemFilter.IsFavoriteOrLikes:
+ query.IsFavoriteOrLiked = true;
+ break;
+ }
+ }
+
+ return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets latest channel items.
+ /// </summary>
+ /// <param name="userId">Optional. User Id.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="channelIds">Optional. Specify one or more channel id's, comma delimited.</param>
+ /// <response code="200">Latest channel items returned.</response>
+ /// <returns>
+ /// A <see cref="Task"/> representing the request to get the latest channel items.
+ /// The task result contains an <see cref="OkResult"/> containing the latest channel items.
+ /// </returns>
+ [HttpGet("Items/Latest")]
+ public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLatestChannelItems(
+ [FromQuery] Guid? userId,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] string? filters,
+ [FromQuery] string? fields,
+ [FromQuery] string? channelIds)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+
+ var query = new InternalItemsQuery(user)
+ {
+ Limit = limit,
+ StartIndex = startIndex,
+ ChannelIds = (channelIds ?? string.Empty)
+ .Split(',')
+ .Where(i => !string.IsNullOrWhiteSpace(i))
+ .Select(i => new Guid(i))
+ .ToArray(),
+ DtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ };
+
+ foreach (var filter in RequestHelpers.GetFilters(filters))
+ {
+ switch (filter)
+ {
+ case ItemFilter.IsFolder:
+ query.IsFolder = true;
+ break;
+ case ItemFilter.IsNotFolder:
+ query.IsFolder = false;
+ break;
+ case ItemFilter.IsUnplayed:
+ query.IsPlayed = false;
+ break;
+ case ItemFilter.IsPlayed:
+ query.IsPlayed = true;
+ break;
+ case ItemFilter.IsFavorite:
+ query.IsFavorite = true;
+ break;
+ case ItemFilter.IsResumable:
+ query.IsResumable = true;
+ break;
+ case ItemFilter.Likes:
+ query.IsLiked = true;
+ break;
+ case ItemFilter.Dislikes:
+ query.IsLiked = false;
+ break;
+ case ItemFilter.IsFavoriteOrLikes:
+ query.IsFavoriteOrLiked = true;
+ break;
+ }
+ }
+
+ return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs
index 29db0b178..6f78a7d84 100644
--- a/Jellyfin.Api/Controllers/CollectionController.cs
+++ b/Jellyfin.Api/Controllers/CollectionController.cs
@@ -44,17 +44,17 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="name">The name of the collection.</param>
/// <param name="ids">Item Ids to add to the collection.</param>
- /// <param name="isLocked">Whether or not to lock the new collection.</param>
/// <param name="parentId">Optional. Create the collection within a specific folder.</param>
+ /// <param name="isLocked">Whether or not to lock the new collection.</param>
/// <response code="200">Collection created.</response>
/// <returns>A <see cref="CollectionCreationOptions"/> with information about the new collection.</returns>
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<CollectionCreationResult> CreateCollection(
- [FromQuery] string name,
- [FromQuery] string ids,
- [FromQuery] bool isLocked,
- [FromQuery] Guid? parentId)
+ [FromQuery] string? name,
+ [FromQuery] string? ids,
+ [FromQuery] Guid? parentId,
+ [FromQuery] bool isLocked = false)
{
var userId = _authContext.GetAuthorizationInfo(Request).UserId;
@@ -86,7 +86,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult AddToCollection([FromRoute] Guid collectionId, [FromQuery] string itemIds)
+ public ActionResult AddToCollection([FromRoute] Guid collectionId, [FromQuery] string? itemIds)
{
_collectionManager.AddToCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
return NoContent();
@@ -101,7 +101,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpDelete("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult RemoveFromCollection([FromRoute] Guid collectionId, [FromQuery] string itemIds)
+ public ActionResult RemoveFromCollection([FromRoute] Guid collectionId, [FromQuery] string? itemIds)
{
_collectionManager.RemoveFromCollection(collectionId, RequestHelpers.Split(itemIds, ',', true));
return NoContent();
diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs
index d275ed2eb..13933cb33 100644
--- a/Jellyfin.Api/Controllers/ConfigurationController.cs
+++ b/Jellyfin.Api/Controllers/ConfigurationController.cs
@@ -70,7 +70,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>Configuration.</returns>
[HttpGet("Configuration/{key}")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<object> GetNamedConfiguration([FromRoute] string key)
+ public ActionResult<object> GetNamedConfiguration([FromRoute] string? key)
{
return _configurationManager.GetConfiguration(key);
}
@@ -84,7 +84,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] string? key)
{
var configurationType = _configurationManager.GetConfigurationType(key);
var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType).ConfigureAwait(false);
diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs
index 6cfee2463..e033c50d5 100644
--- a/Jellyfin.Api/Controllers/DashboardController.cs
+++ b/Jellyfin.Api/Controllers/DashboardController.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Jellyfin.Api.Models;
@@ -122,7 +121,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("/web/ConfigurationPage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public ActionResult GetDashboardConfigurationPage([FromQuery] string name)
+ public ActionResult GetDashboardConfigurationPage([FromQuery] string? name)
{
IPlugin? plugin = null;
Stream? stream = null;
diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs
index 55ca7b7c0..3cf7b3378 100644
--- a/Jellyfin.Api/Controllers/DevicesController.cs
+++ b/Jellyfin.Api/Controllers/DevicesController.cs
@@ -65,7 +65,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, BindRequired] string id)
+ public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, BindRequired] string? id)
{
var deviceInfo = _deviceManager.GetDevice(id);
if (deviceInfo == null)
@@ -87,7 +87,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, BindRequired] string id)
+ public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, BindRequired] string? id)
{
var deviceInfo = _deviceManager.GetDeviceOptions(id);
if (deviceInfo == null)
@@ -111,7 +111,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateDeviceOptions(
- [FromQuery, BindRequired] string id,
+ [FromQuery, BindRequired] string? id,
[FromBody, BindRequired] DeviceOptions deviceOptions)
{
var existingDeviceOptions = _deviceManager.GetDeviceOptions(id);
@@ -134,7 +134,7 @@ namespace Jellyfin.Api.Controllers
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public ActionResult DeleteDevice([FromQuery, BindRequired] string id)
+ public ActionResult DeleteDevice([FromQuery, BindRequired] string? id)
{
var existingDevice = _deviceManager.GetDevice(id);
if (existingDevice == null)
diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
index 3f946d9d2..1255e6dab 100644
--- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
+++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
@@ -39,9 +39,9 @@ namespace Jellyfin.Api.Controllers
[HttpGet("{displayPreferencesId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<DisplayPreferences> GetDisplayPreferences(
- [FromRoute] string displayPreferencesId,
- [FromQuery] [Required] string userId,
- [FromQuery] [Required] string client)
+ [FromRoute] string? displayPreferencesId,
+ [FromQuery] [Required] string? userId,
+ [FromQuery] [Required] string? client)
{
return _displayPreferencesRepository.GetDisplayPreferences(displayPreferencesId, userId, client);
}
@@ -59,9 +59,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,
- [FromQuery, BindRequired] string userId,
- [FromQuery, BindRequired] string client,
+ [FromRoute] string? displayPreferencesId,
+ [FromQuery, BindRequired] string? userId,
+ [FromQuery, BindRequired] string? client,
[FromBody, BindRequired] DisplayPreferences displayPreferences)
{
_displayPreferencesRepository.SaveDisplayPreferences(
diff --git a/Jellyfin.Api/Controllers/EnvironmentController.cs b/Jellyfin.Api/Controllers/EnvironmentController.cs
new file mode 100644
index 000000000..719bb7d86
--- /dev/null
+++ b/Jellyfin.Api/Controllers/EnvironmentController.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Models.EnvironmentDtos;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Environment Controller.
+ /// </summary>
+ [Authorize(Policy = Policies.RequiresElevation)]
+ public class EnvironmentController : BaseJellyfinApiController
+ {
+ private const char UncSeparator = '\\';
+ private const string UncStartPrefix = @"\\";
+
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger<EnvironmentController> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="EnvironmentController"/> class.
+ /// </summary>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{EnvironmentController}"/> interface.</param>
+ public EnvironmentController(IFileSystem fileSystem, ILogger<EnvironmentController> logger)
+ {
+ _fileSystem = fileSystem;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Gets the contents of a given directory in the file system.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <param name="includeFiles">An optional filter to include or exclude files from the results. true/false.</param>
+ /// <param name="includeDirectories">An optional filter to include or exclude folders from the results. true/false.</param>
+ /// <response code="200">Directory contents returned.</response>
+ /// <returns>Directory contents.</returns>
+ [HttpGet("DirectoryContents")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public IEnumerable<FileSystemEntryInfo> GetDirectoryContents(
+ [FromQuery, BindRequired] string path,
+ [FromQuery] bool includeFiles = false,
+ [FromQuery] bool includeDirectories = false)
+ {
+ if (path.StartsWith(UncStartPrefix, StringComparison.OrdinalIgnoreCase)
+ && path.LastIndexOf(UncSeparator) == 1)
+ {
+ return Array.Empty<FileSystemEntryInfo>();
+ }
+
+ var entries =
+ _fileSystem.GetFileSystemEntries(path)
+ .Where(i => (i.IsDirectory && includeDirectories) || (!i.IsDirectory && includeFiles))
+ .OrderBy(i => i.FullName);
+
+ return entries.Select(f => new FileSystemEntryInfo(f.Name, f.FullName, f.IsDirectory ? FileSystemEntryType.Directory : FileSystemEntryType.File));
+ }
+
+ /// <summary>
+ /// Validates path.
+ /// </summary>
+ /// <param name="validatePathDto">Validate request object.</param>
+ /// <response code="200">Path validated.</response>
+ /// <response code="404">Path not found.</response>
+ /// <returns>Validation status.</returns>
+ [HttpPost("ValidatePath")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult ValidatePath([FromBody, BindRequired] ValidatePathDto validatePathDto)
+ {
+ if (validatePathDto.IsFile.HasValue)
+ {
+ if (validatePathDto.IsFile.Value)
+ {
+ if (!System.IO.File.Exists(validatePathDto.Path))
+ {
+ return NotFound();
+ }
+ }
+ else
+ {
+ if (!Directory.Exists(validatePathDto.Path))
+ {
+ return NotFound();
+ }
+ }
+ }
+ else
+ {
+ if (!System.IO.File.Exists(validatePathDto.Path) && !Directory.Exists(validatePathDto.Path))
+ {
+ return NotFound();
+ }
+
+ if (validatePathDto.ValidateWritable)
+ {
+ var file = Path.Combine(validatePathDto.Path, Guid.NewGuid().ToString());
+ try
+ {
+ System.IO.File.WriteAllText(file, string.Empty);
+ }
+ finally
+ {
+ if (System.IO.File.Exists(file))
+ {
+ System.IO.File.Delete(file);
+ }
+ }
+ }
+ }
+
+ return Ok();
+ }
+
+ /// <summary>
+ /// Gets network paths.
+ /// </summary>
+ /// <response code="200">Empty array returned.</response>
+ /// <returns>List of entries.</returns>
+ [Obsolete("This endpoint is obsolete.")]
+ [HttpGet("NetworkShares")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares()
+ {
+ _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares");
+ return Array.Empty<FileSystemEntryInfo>();
+ }
+
+ /// <summary>
+ /// Gets available drives from the server's file system.
+ /// </summary>
+ /// <response code="200">List of entries returned.</response>
+ /// <returns>List of entries.</returns>
+ [HttpGet("Drives")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public IEnumerable<FileSystemEntryInfo> GetDrives()
+ {
+ return _fileSystem.GetDrives().Select(d => new FileSystemEntryInfo(d.Name, d.FullName, FileSystemEntryType.Directory));
+ }
+
+ /// <summary>
+ /// Gets the parent path of a given path.
+ /// </summary>
+ /// <param name="path">The path.</param>
+ /// <returns>Parent path.</returns>
+ [HttpGet("ParentPath")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<string?> GetParentPath([FromQuery, BindRequired] string path)
+ {
+ string? parent = Path.GetDirectoryName(path);
+ if (string.IsNullOrEmpty(parent))
+ {
+ // Check if unc share
+ var index = path.LastIndexOf(UncSeparator);
+
+ if (index != -1 && path.IndexOf(UncSeparator, StringComparison.OrdinalIgnoreCase) == 0)
+ {
+ parent = path.Substring(0, index);
+
+ if (string.IsNullOrWhiteSpace(parent.TrimStart(UncSeparator)))
+ {
+ parent = null;
+ }
+ }
+ }
+
+ return parent;
+ }
+
+ /// <summary>
+ /// Get Default directory browser.
+ /// </summary>
+ /// <response code="200">Default directory browser returned.</response>
+ /// <returns>Default directory browser.</returns>
+ [HttpGet("DefaultDirectoryBrowser")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<DefaultDirectoryBrowserInfoDto> GetDefaultDirectoryBrowser()
+ {
+ return new DefaultDirectoryBrowserInfoDto();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs
index 8a0a6ad86..9ba5e1161 100644
--- a/Jellyfin.Api/Controllers/FilterController.cs
+++ b/Jellyfin.Api/Controllers/FilterController.cs
@@ -1,5 +1,4 @@
using System;
-using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Jellyfin.Api.Constants;
using MediaBrowser.Controller.Dto;
@@ -57,9 +56,9 @@ namespace Jellyfin.Api.Controllers
? null
: _libraryManager.GetItemById(parentId);
- var user = userId == null || userId == Guid.Empty
- ? null
- : _userManager.GetUserById(userId.Value);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
@@ -152,9 +151,9 @@ namespace Jellyfin.Api.Controllers
? null
: _libraryManager.GetItemById(parentId);
- var user = userId == null || userId == Guid.Empty
- ? null
- : _userManager.GetUserById(userId.Value);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
|| string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs
new file mode 100644
index 000000000..55ad71200
--- /dev/null
+++ b/Jellyfin.Api/Controllers/GenresController.cs
@@ -0,0 +1,320 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+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;
+using Genre = MediaBrowser.Controller.Entities.Genre;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// The genres controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class GenresController : BaseJellyfinApiController
+ {
+ private readonly IUserManager _userManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IDtoService _dtoService;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GenresController"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ public GenresController(
+ IUserManager userManager,
+ ILibraryManager libraryManager,
+ IDtoService dtoService)
+ {
+ _userManager = userManager;
+ _libraryManager = libraryManager;
+ _dtoService = dtoService;
+ }
+
+ /// <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>
+ /// <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="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>
+ /// <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">Genres returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the queryresult of genres.</returns>
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetGenres(
+ [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] 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] Guid? userId,
+ [FromQuery] string? nameStartsWithOrGreater,
+ [FromQuery] string? nameStartsWith,
+ [FromQuery] string? nameLessThan,
+ [FromQuery] bool? enableImages = true,
+ [FromQuery] bool enableTotalRecordCount = true)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .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)
+ {
+ 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;
+ });
+
+ return new QueryResult<BaseItemDto>
+ {
+ Items = dtos.ToArray(),
+ TotalRecordCount = result.TotalRecordCount
+ };
+ }
+
+ /// <summary>
+ /// Gets a genre, by name.
+ /// </summary>
+ /// <param name="genreName">The genre name.</param>
+ /// <param name="userId">The user id.</param>
+ /// <response code="200">Genres returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the genre.</returns>
+ [HttpGet("{genreName}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<BaseItemDto> GetGenre([FromRoute] string genreName, [FromQuery] Guid? userId)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddClientFields(Request);
+
+ Genre item = new Genre();
+ if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ var result = GetItemFromSlugName<Genre>(_libraryManager, genreName, dtoOptions);
+
+ if (result != null)
+ {
+ item = result;
+ }
+ }
+ else
+ {
+ item = _libraryManager.GetGenre(genreName);
+ }
+
+ if (userId.HasValue && !userId.Equals(Guid.Empty))
+ {
+ var user = _userManager.GetUserById(userId.Value);
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+ }
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions);
+ }
+
+ private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions)
+ where T : BaseItem, new()
+ {
+ var result = libraryManager.GetItemList(new InternalItemsQuery
+ {
+ Name = name.Replace(BaseItem.SlugChar, '&'),
+ IncludeItemTypes = new[] { typeof(T).Name },
+ DtoOptions = dtoOptions
+ }).OfType<T>().FirstOrDefault();
+
+ result ??= libraryManager.GetItemList(new InternalItemsQuery
+ {
+ Name = name.Replace(BaseItem.SlugChar, '/'),
+ IncludeItemTypes = new[] { typeof(T).Name },
+ DtoOptions = dtoOptions
+ }).OfType<T>().FirstOrDefault();
+
+ result ??= libraryManager.GetItemList(new InternalItemsQuery
+ {
+ Name = name.Replace(BaseItem.SlugChar, '?'),
+ IncludeItemTypes = new[] { typeof(T).Name },
+ DtoOptions = dtoOptions
+ }).OfType<T>().FirstOrDefault();
+
+ return result;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/ImageByNameController.cs b/Jellyfin.Api/Controllers/ImageByNameController.cs
index 4800c0608..5244c35b8 100644
--- a/Jellyfin.Api/Controllers/ImageByNameController.cs
+++ b/Jellyfin.Api/Controllers/ImageByNameController.cs
@@ -64,7 +64,7 @@ namespace Jellyfin.Api.Controllers
[Produces(MediaTypeNames.Application.Octet)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public ActionResult<FileStreamResult> GetGeneralImage([FromRoute] string name, [FromRoute] string type)
+ public ActionResult<FileStreamResult> GetGeneralImage([FromRoute] string? name, [FromRoute] string? type)
{
var filename = string.Equals(type, "primary", StringComparison.OrdinalIgnoreCase)
? "folder"
@@ -110,8 +110,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<FileStreamResult> GetRatingImage(
- [FromRoute] string theme,
- [FromRoute] string name)
+ [FromRoute] string? theme,
+ [FromRoute] string? name)
{
return GetImageFile(_applicationPaths.RatingsPath, theme, name);
}
@@ -143,8 +143,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<FileStreamResult> GetMediaInfoImage(
- [FromRoute] string theme,
- [FromRoute] string name)
+ [FromRoute] string? theme,
+ [FromRoute] string? name)
{
return GetImageFile(_applicationPaths.MediaInfoImagesPath, theme, name);
}
@@ -156,7 +156,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<FileStreamResult> GetImageFile(string basePath, string? theme, string? name)
{
var themeFolder = Path.Combine(basePath, theme);
if (Directory.Exists(themeFolder))
diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs
new file mode 100644
index 000000000..bb980af3e
--- /dev/null
+++ b/Jellyfin.Api/Controllers/InstantMixController.cs
@@ -0,0 +1,328 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// The instant mix controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class InstantMixController : BaseJellyfinApiController
+ {
+ private readonly IUserManager _userManager;
+ private readonly IDtoService _dtoService;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IMusicManager _musicManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="InstantMixController"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ /// <param name="musicManager">Instance of the <see cref="IMusicManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ public InstantMixController(
+ IUserManager userManager,
+ IDtoService dtoService,
+ IMusicManager musicManager,
+ ILibraryManager libraryManager)
+ {
+ _userManager = userManager;
+ _dtoService = dtoService;
+ _musicManager = musicManager;
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Creates an instant playlist based on a given song.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <response code="200">Instant playlist returned.</response>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+ [HttpGet("/Songs/{id}/InstantMix")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong(
+ [FromRoute] Guid id,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? limit,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableImages,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes)
+ {
+ var item = _libraryManager.GetItemById(id);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+ var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+ return GetResult(items, user, limit, dtoOptions);
+ }
+
+ /// <summary>
+ /// Creates an instant playlist based on a given song.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <response code="200">Instant playlist returned.</response>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+ [HttpGet("/Albums/{id}/InstantMix")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum(
+ [FromRoute] Guid id,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? limit,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableImages,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes)
+ {
+ var album = _libraryManager.GetItemById(id);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+ var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions);
+ return GetResult(items, user, limit, dtoOptions);
+ }
+
+ /// <summary>
+ /// Creates an instant playlist based on a given song.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <response code="200">Instant playlist returned.</response>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+ [HttpGet("/Playlists/{id}/InstantMix")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist(
+ [FromRoute] Guid id,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? limit,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableImages,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes)
+ {
+ var playlist = (Playlist)_libraryManager.GetItemById(id);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+ var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions);
+ return GetResult(items, user, limit, dtoOptions);
+ }
+
+ /// <summary>
+ /// Creates an instant playlist based on a given song.
+ /// </summary>
+ /// <param name="name">The genre name.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <response code="200">Instant playlist returned.</response>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+ [HttpGet("/MusicGenres/{name}/InstantMix")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenre(
+ [FromRoute] string? name,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? limit,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableImages,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+ var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions);
+ return GetResult(items, user, limit, dtoOptions);
+ }
+
+ /// <summary>
+ /// Creates an instant playlist based on a given song.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <response code="200">Instant playlist returned.</response>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+ [HttpGet("/Artists/InstantMix")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
+ [FromRoute] Guid id,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? limit,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableImages,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes)
+ {
+ var item = _libraryManager.GetItemById(id);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+ var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+ return GetResult(items, user, limit, dtoOptions);
+ }
+
+ /// <summary>
+ /// Creates an instant playlist based on a given song.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <response code="200">Instant playlist returned.</response>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+ [HttpGet("/MusicGenres/InstantMix")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres(
+ [FromRoute] Guid id,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? limit,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableImages,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes)
+ {
+ var item = _libraryManager.GetItemById(id);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+ var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+ return GetResult(items, user, limit, dtoOptions);
+ }
+
+ /// <summary>
+ /// Creates an instant playlist based on a given song.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <response code="200">Instant playlist returned.</response>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
+ [HttpGet("/Items/{id}/InstantMix")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem(
+ [FromRoute] Guid id,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? limit,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableImages,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes)
+ {
+ var item = _libraryManager.GetItemById(id);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
+ var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
+ return GetResult(items, user, limit, dtoOptions);
+ }
+
+ private QueryResult<BaseItemDto> GetResult(List<BaseItem> items, User? user, int? limit, DtoOptions dtoOptions)
+ {
+ var list = items;
+
+ var result = new QueryResult<BaseItemDto>
+ {
+ TotalRecordCount = list.Count
+ };
+
+ if (limit.HasValue)
+ {
+ list = list.Take(limit.Value).ToList();
+ }
+
+ var returnList = _dtoService.GetBaseItemDtos(list, dtoOptions, user);
+
+ result.Items = returnList;
+
+ return result;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs
index 3801ce5b7..4697d869d 100644
--- a/Jellyfin.Api/Controllers/ItemRefreshController.cs
+++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs
@@ -1,6 +1,5 @@
using System;
using System.ComponentModel;
-using System.Diagnostics.CodeAnalysis;
using Jellyfin.Api.Constants;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 384f250ec..c9b2aafcc 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -193,7 +193,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Items/{itemId}/ContentType")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public ActionResult UpdateItemContentType([FromRoute] Guid itemId, [FromQuery, BindRequired] string contentType)
+ public ActionResult UpdateItemContentType([FromRoute] Guid itemId, [FromQuery, BindRequired] string? contentType)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
new file mode 100644
index 000000000..41fe47db1
--- /dev/null
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -0,0 +1,592 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// The items controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class ItemsController : BaseJellyfinApiController
+ {
+ private readonly IUserManager _userManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILocalizationManager _localization;
+ private readonly IDtoService _dtoService;
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ItemsController"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ public ItemsController(
+ IUserManager userManager,
+ ILibraryManager libraryManager,
+ ILocalizationManager localization,
+ IDtoService dtoService,
+ ILogger<ItemsController> logger)
+ {
+ _userManager = userManager;
+ _libraryManager = libraryManager;
+ _localization = localization;
+ _dtoService = dtoService;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Gets items based on a query.
+ /// </summary>
+ /// <param name="uId">The user id supplied in the /Users/{uid}/Items.</param>
+ /// <param name="userId">The user id supplied as query parameter.</param>
+ /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
+ /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
+ /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
+ /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
+ /// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
+ /// <param name="hasTrailer">Optional filter by items with trailers.</param>
+ /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+ /// <param name="parentIndexNumber">Optional filter by parent index number.</param>
+ /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
+ /// <param name="isHd">Optional filter by items that are HD or not.</param>
+ /// <param name="is4K">Optional filter by items that are 4K or not.</param>
+ /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param>
+ /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param>
+ /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
+ /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
+ /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+ /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
+ /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
+ /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
+ /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
+ /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
+ /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
+ /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param>
+ /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param>
+ /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param>
+ /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
+ /// <param name="searchTerm">Optional. Filter based on a search term.</param>
+ /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
+ /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+ /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+ /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+ /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+ /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
+ /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param>
+ /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
+ /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
+ /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param>
+ /// <param name="enableUserData">Optional, include user data.</param>
+ /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+ /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
+ /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+ /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param>
+ /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
+ /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param>
+ /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
+ /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
+ /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
+ /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param>
+ /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param>
+ /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
+ /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param>
+ /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
+ /// <param name="isLocked">Optional filter by items that are locked.</param>
+ /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
+ /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
+ /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
+ /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
+ /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
+ /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
+ /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
+ /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
+ /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param>
+ /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+ /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+ /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+ /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param>
+ /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param>
+ /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+ /// <param name="enableImages">Optional, include image information in output.</param>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
+ [HttpGet("/Items")]
+ [HttpGet("/Users/{uId}/Items")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetItems(
+ [FromRoute] Guid? uId,
+ [FromQuery] Guid? userId,
+ [FromQuery] string? maxOfficialRating,
+ [FromQuery] bool? hasThemeSong,
+ [FromQuery] bool? hasThemeVideo,
+ [FromQuery] bool? hasSubtitles,
+ [FromQuery] bool? hasSpecialFeature,
+ [FromQuery] bool? hasTrailer,
+ [FromQuery] string? adjacentTo,
+ [FromQuery] int? parentIndexNumber,
+ [FromQuery] bool? hasParentalRating,
+ [FromQuery] bool? isHd,
+ [FromQuery] bool? is4K,
+ [FromQuery] string? locationTypes,
+ [FromQuery] string? excludeLocationTypes,
+ [FromQuery] bool? isMissing,
+ [FromQuery] bool? isUnaired,
+ [FromQuery] double? minCommunityRating,
+ [FromQuery] double? minCriticRating,
+ [FromQuery] DateTime? minPremiereDate,
+ [FromQuery] DateTime? minDateLastSaved,
+ [FromQuery] DateTime? minDateLastSavedForUser,
+ [FromQuery] DateTime? maxPremiereDate,
+ [FromQuery] bool? hasOverview,
+ [FromQuery] bool? hasImdbId,
+ [FromQuery] bool? hasTmdbId,
+ [FromQuery] bool? hasTvdbId,
+ [FromQuery] string? excludeItemIds,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] bool? recursive,
+ [FromQuery] string? searchTerm,
+ [FromQuery] string? sortOrder,
+ [FromQuery] string? parentId,
+ [FromQuery] string? fields,
+ [FromQuery] string? excludeItemTypes,
+ [FromQuery] string? includeItemTypes,
+ [FromQuery] string? filters,
+ [FromQuery] bool? isFavorite,
+ [FromQuery] string? mediaTypes,
+ [FromQuery] string? imageTypes,
+ [FromQuery] string? sortBy,
+ [FromQuery] bool? isPlayed,
+ [FromQuery] string? genres,
+ [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? artists,
+ [FromQuery] string? excludeArtistIds,
+ [FromQuery] string? artistIds,
+ [FromQuery] string? albumArtistIds,
+ [FromQuery] string? contributingArtistIds,
+ [FromQuery] string? albums,
+ [FromQuery] string? albumIds,
+ [FromQuery] string? ids,
+ [FromQuery] string? videoTypes,
+ [FromQuery] string? minOfficialRating,
+ [FromQuery] bool? isLocked,
+ [FromQuery] bool? isPlaceHolder,
+ [FromQuery] bool? hasOfficialRating,
+ [FromQuery] bool? collapseBoxSetItems,
+ [FromQuery] int? minWidth,
+ [FromQuery] int? minHeight,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] bool? is3D,
+ [FromQuery] string? seriesStatus,
+ [FromQuery] string? nameStartsWithOrGreater,
+ [FromQuery] string? nameStartsWith,
+ [FromQuery] string? nameLessThan,
+ [FromQuery] string? studioIds,
+ [FromQuery] string? genreIds,
+ [FromQuery] bool enableTotalRecordCount = true,
+ [FromQuery] bool? enableImages = true)
+ {
+ // use user id route parameter over query parameter
+ userId = uId ?? userId;
+
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+ if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase))
+ {
+ parentId = null;
+ }
+
+ BaseItem? item = null;
+ QueryResult<BaseItem> result;
+ if (!string.IsNullOrEmpty(parentId))
+ {
+ item = _libraryManager.GetItemById(parentId);
+ }
+
+ item ??= _libraryManager.GetUserRootFolder();
+
+ if (!(item is Folder folder))
+ {
+ folder = _libraryManager.GetUserRootFolder();
+ }
+
+ if (folder is IHasCollectionType hasCollectionType
+ && string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
+ {
+ recursive = true;
+ includeItemTypes = "Playlist";
+ }
+
+ 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);
+
+ var collectionFolders = _libraryManager.GetCollectionFolders(item);
+ foreach (var collectionFolder in collectionFolders)
+ {
+ if (user.GetPreference(PreferenceKind.EnabledFolders).Contains(
+ collectionFolder.Id.ToString("N", CultureInfo.InvariantCulture),
+ StringComparer.OrdinalIgnoreCase))
+ {
+ isInEnabledFolder = true;
+ }
+ }
+
+ if (!(item is UserRootFolder)
+ && !isInEnabledFolder
+ && !user.HasPermission(PermissionKind.EnableAllFolders)
+ && !user.HasPermission(PermissionKind.EnableAllChannels))
+ {
+ _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}.", user.Username, item.Name);
+ return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
+ }
+
+ if ((recursive.HasValue && recursive.Value) || !string.IsNullOrEmpty(ids) || !(item is UserRootFolder))
+ {
+ var query = new InternalItemsQuery(user!)
+ {
+ IsPlayed = isPlayed,
+ MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+ IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+ ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+ Recursive = recursive ?? false,
+ OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+ IsFavorite = isFavorite,
+ Limit = limit,
+ StartIndex = startIndex,
+ IsMissing = isMissing,
+ IsUnaired = isUnaired,
+ CollapseBoxSetItems = collapseBoxSetItems,
+ NameLessThan = nameLessThan,
+ NameStartsWith = nameStartsWith,
+ NameStartsWithOrGreater = nameStartsWithOrGreater,
+ HasImdbId = hasImdbId,
+ IsPlaceHolder = isPlaceHolder,
+ IsLocked = isLocked,
+ MinWidth = minWidth,
+ MinHeight = minHeight,
+ MaxWidth = maxWidth,
+ MaxHeight = maxHeight,
+ Is3D = is3D,
+ HasTvdbId = hasTvdbId,
+ HasTmdbId = hasTmdbId,
+ HasOverview = hasOverview,
+ HasOfficialRating = hasOfficialRating,
+ HasParentalRating = hasParentalRating,
+ HasSpecialFeature = hasSpecialFeature,
+ HasSubtitles = hasSubtitles,
+ HasThemeSong = hasThemeSong,
+ HasThemeVideo = hasThemeVideo,
+ HasTrailer = hasTrailer,
+ IsHD = isHd,
+ Is4K = is4K,
+ Tags = RequestHelpers.Split(tags, '|', true),
+ OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
+ Genres = RequestHelpers.Split(genres, '|', true),
+ ArtistIds = RequestHelpers.GetGuids(artistIds),
+ AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds),
+ ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds),
+ GenreIds = RequestHelpers.GetGuids(genreIds),
+ StudioIds = RequestHelpers.GetGuids(studioIds),
+ Person = person,
+ 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(),
+ VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(),
+ AdjacentTo = adjacentTo,
+ ItemIds = RequestHelpers.GetGuids(ids),
+ MinCommunityRating = minCommunityRating,
+ MinCriticRating = minCriticRating,
+ ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId),
+ ParentIndexNumber = parentIndexNumber,
+ EnableTotalRecordCount = enableTotalRecordCount,
+ ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds),
+ DtoOptions = dtoOptions,
+ SearchTerm = searchTerm,
+ MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
+ MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
+ MinPremiereDate = minPremiereDate?.ToUniversalTime(),
+ MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
+ };
+
+ if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm))
+ {
+ query.CollapseBoxSetItems = false;
+ }
+
+ 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;
+ }
+ }
+
+ // Filter by Series Status
+ if (!string.IsNullOrEmpty(seriesStatus))
+ {
+ query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray();
+ }
+
+ // ExcludeLocationTypes
+ if (!string.IsNullOrEmpty(excludeLocationTypes))
+ {
+ if (excludeLocationTypes.Split(',').Select(d => (LocationType)Enum.Parse(typeof(LocationType), d, true)).ToArray().Contains(LocationType.Virtual))
+ {
+ query.IsVirtualItem = false;
+ }
+ }
+
+ if (!string.IsNullOrEmpty(locationTypes))
+ {
+ var requestedLocationTypes = locationTypes.Split(',');
+ if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4)
+ {
+ query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString());
+ }
+ }
+
+ // Min official rating
+ if (!string.IsNullOrWhiteSpace(minOfficialRating))
+ {
+ query.MinParentalRating = _localization.GetRatingLevel(minOfficialRating);
+ }
+
+ // Max official rating
+ if (!string.IsNullOrWhiteSpace(maxOfficialRating))
+ {
+ query.MaxParentalRating = _localization.GetRatingLevel(maxOfficialRating);
+ }
+
+ // Artists
+ if (!string.IsNullOrEmpty(artists))
+ {
+ query.ArtistIds = artists.Split('|').Select(i =>
+ {
+ try
+ {
+ return _libraryManager.GetArtist(i, new DtoOptions(false));
+ }
+ catch
+ {
+ return null;
+ }
+ }).Where(i => i != null).Select(i => i!.Id).ToArray();
+ }
+
+ // ExcludeArtistIds
+ if (!string.IsNullOrWhiteSpace(excludeArtistIds))
+ {
+ query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+ }
+
+ if (!string.IsNullOrWhiteSpace(albumIds))
+ {
+ query.AlbumIds = RequestHelpers.GetGuids(albumIds);
+ }
+
+ // Albums
+ if (!string.IsNullOrEmpty(albums))
+ {
+ query.AlbumIds = albums.Split('|').SelectMany(i =>
+ {
+ return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 });
+ }).ToArray();
+ }
+
+ // 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();
+ }
+
+ // Apply default sorting if none requested
+ if (query.OrderBy.Count == 0)
+ {
+ // Albums by artist
+ if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && string.Equals(query.IncludeItemTypes[0], "MusicAlbum", StringComparison.OrdinalIgnoreCase))
+ {
+ query.OrderBy = new[] { new ValueTuple<string, SortOrder>(ItemSortBy.ProductionYear, SortOrder.Descending), new ValueTuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending) };
+ }
+ }
+
+ result = folder.GetItems(query);
+ }
+ else
+ {
+ var itemsArray = folder.GetChildren(user, true);
+ result = new QueryResult<BaseItem> { Items = itemsArray, TotalRecordCount = itemsArray.Count, StartIndex = 0 };
+ }
+
+ return new QueryResult<BaseItemDto> { StartIndex = startIndex.GetValueOrDefault(), TotalRecordCount = result.TotalRecordCount, Items = _dtoService.GetBaseItemDtos(result.Items, dtoOptions, user) };
+ }
+
+ /// <summary>
+ /// Gets items based on a query.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="startIndex">The start index.</param>
+ /// <param name="limit">The item limit.</param>
+ /// <param name="searchTerm">The search term.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
+ /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <response code="200">Items returned.</response>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items that are resumable.</returns>
+ [HttpGet("/Users/{userId}/Items/Resume")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetResumeItems(
+ [FromRoute] Guid userId,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] string? searchTerm,
+ [FromQuery] string? parentId,
+ [FromQuery] string? fields,
+ [FromQuery] string? mediaTypes,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes,
+ [FromQuery] string? excludeItemTypes,
+ [FromQuery] string? includeItemTypes,
+ [FromQuery] bool enableTotalRecordCount = true,
+ [FromQuery] bool? enableImages = true)
+ {
+ var user = _userManager.GetUserById(userId);
+ var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+ var ancestorIds = Array.Empty<Guid>();
+
+ var excludeFolderIds = user.GetPreference(PreferenceKind.LatestItemExcludes);
+ if (parentIdGuid.Equals(Guid.Empty) && excludeFolderIds.Length > 0)
+ {
+ ancestorIds = _libraryManager.GetUserRootFolder().GetChildren(user, true)
+ .Where(i => i is Folder)
+ .Where(i => !excludeFolderIds.Contains(i.Id.ToString("N", CultureInfo.InvariantCulture)))
+ .Select(i => i.Id)
+ .ToArray();
+ }
+
+ var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
+ {
+ OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
+ IsResumable = true,
+ StartIndex = startIndex,
+ Limit = limit,
+ ParentId = parentIdGuid,
+ Recursive = true,
+ DtoOptions = dtoOptions,
+ MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+ IsVirtualItem = false,
+ CollapseBoxSetItems = false,
+ EnableTotalRecordCount = enableTotalRecordCount,
+ AncestorIds = ancestorIds,
+ IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+ ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+ SearchTerm = searchTerm
+ });
+
+ var returnItems = _dtoService.GetBaseItemDtos(itemsResult.Items, dtoOptions, user);
+
+ return new QueryResult<BaseItemDto>
+ {
+ StartIndex = startIndex.GetValueOrDefault(),
+ TotalRecordCount = itemsResult.TotalRecordCount,
+ Items = returnItems
+ };
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index 1ecf2ac73..5ad466c55 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -146,11 +145,11 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<ThemeMediaResult> GetThemeSongs(
[FromRoute] Guid itemId,
- [FromQuery] Guid userId,
- [FromQuery] bool inheritFromParent)
+ [FromQuery] Guid? userId,
+ [FromQuery] bool inheritFromParent = false)
{
- var user = !userId.Equals(Guid.Empty)
- ? _userManager.GetUserById(userId)
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
: null;
var item = itemId.Equals(Guid.Empty)
@@ -212,11 +211,11 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<ThemeMediaResult> GetThemeVideos(
[FromRoute] Guid itemId,
- [FromQuery] Guid userId,
- [FromQuery] bool inheritFromParent)
+ [FromQuery] Guid? userId,
+ [FromQuery] bool inheritFromParent = false)
{
- var user = !userId.Equals(Guid.Empty)
- ? _userManager.GetUserById(userId)
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
: null;
var item = itemId.Equals(Guid.Empty)
@@ -277,8 +276,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<AllThemeMediaResult> GetThemeMedia(
[FromRoute] Guid itemId,
- [FromQuery] Guid userId,
- [FromQuery] bool inheritFromParent)
+ [FromQuery] Guid? userId,
+ [FromQuery] bool inheritFromParent = false)
{
var themeSongs = GetThemeSongs(
itemId,
@@ -361,12 +360,14 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
- public ActionResult DeleteItems([FromQuery] string ids)
+ public ActionResult DeleteItems([FromQuery] string? ids)
{
- var itemIds = string.IsNullOrWhiteSpace(ids)
- ? Array.Empty<string>()
- : RequestHelpers.Split(ids, ',', true);
+ if (string.IsNullOrEmpty(ids))
+ {
+ return NoContent();
+ }
+ var itemIds = RequestHelpers.Split(ids, ',', true);
foreach (var i in itemIds)
{
var item = _libraryManager.GetItemById(i);
@@ -403,12 +404,12 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<ItemCounts> GetItemCounts(
- [FromQuery] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery] bool? isFavorite)
{
- var user = userId.Equals(Guid.Empty)
- ? null
- : _userManager.GetUserById(userId);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
var counts = new ItemCounts
{
@@ -437,7 +438,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] Guid itemId, [FromQuery] Guid? userId)
{
var item = _libraryManager.GetItemById(itemId);
@@ -448,8 +449,8 @@ namespace Jellyfin.Api.Controllers
var baseItemDtos = new List<BaseItemDto>();
- var user = !userId.Equals(Guid.Empty)
- ? _userManager.GetUserById(userId)
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
: null;
var dtoOptions = new DtoOptions().AddClientFields(Request);
@@ -524,7 +525,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Library/Series/Updated")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult PostUpdatedSeries([FromQuery] string tvdbId)
+ public ActionResult PostUpdatedSeries([FromQuery] string? tvdbId)
{
var series = _libraryManager.GetItemList(new InternalItemsQuery
{
@@ -554,7 +555,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([FromRoute] string? tmdbId, [FromRoute] string? imdbId)
{
var movies = _libraryManager.GetItemList(new InternalItemsQuery
{
@@ -687,10 +688,10 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
[FromRoute] Guid itemId,
- [FromQuery] string excludeArtistIds,
- [FromQuery] Guid userId,
+ [FromQuery] string? excludeArtistIds,
+ [FromQuery] Guid? userId,
[FromQuery] int? limit,
- [FromQuery] string fields)
+ [FromQuery] string? fields)
{
var item = itemId.Equals(Guid.Empty)
? (!userId.Equals(Guid.Empty)
@@ -737,7 +738,9 @@ namespace Jellyfin.Api.Controllers
[HttpGet("/Libraries/AvailableOptions")]
[Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo([FromQuery] string libraryContentType, [FromQuery] bool isNewLibrary)
+ public ActionResult<LibraryOptionsResultDto> GetLibraryOptionsInfo(
+ [FromQuery] string? libraryContentType,
+ [FromQuery] bool isNewLibrary = false)
{
var result = new LibraryOptionsResultDto();
@@ -877,14 +880,16 @@ namespace Jellyfin.Api.Controllers
private QueryResult<BaseItemDto> GetSimilarItemsResult(
BaseItem item,
- string excludeArtistIds,
- Guid userId,
+ string? excludeArtistIds,
+ Guid? userId,
int? limit,
- string fields,
+ string? fields,
string[] includeItemTypes,
bool isMovie)
{
- var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null;
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
var dtoOptions = new DtoOptions()
.AddItemFields(fields)
.AddClientFields(Request);
@@ -942,7 +947,7 @@ namespace Jellyfin.Api.Controllers
return result;
}
- private static string[] GetRepresentativeItemTypes(string contentType)
+ private static string[] GetRepresentativeItemTypes(string? contentType)
{
return contentType switch
{
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index e4ac019c9..d3537a7a7 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -64,19 +63,19 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="name">The name of the virtual folder.</param>
/// <param name="collectionType">The type of the collection.</param>
- /// <param name="refreshLibrary">Whether to refresh the library.</param>
/// <param name="paths">The paths of the virtual folder.</param>
/// <param name="libraryOptions">The library options.</param>
+ /// <param name="refreshLibrary">Whether to refresh the library.</param>
/// <response code="204">Folder added.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> AddVirtualFolder(
- [FromQuery] string name,
- [FromQuery] string collectionType,
- [FromQuery] bool refreshLibrary,
+ [FromQuery] string? name,
+ [FromQuery] string? collectionType,
[FromQuery] string[] paths,
- [FromQuery] LibraryOptions libraryOptions)
+ [FromQuery] LibraryOptions? libraryOptions,
+ [FromQuery] bool refreshLibrary = false)
{
libraryOptions ??= new LibraryOptions();
@@ -100,8 +99,8 @@ namespace Jellyfin.Api.Controllers
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> RemoveVirtualFolder(
- [FromQuery] string name,
- [FromQuery] bool refreshLibrary)
+ [FromQuery] string? name,
+ [FromQuery] bool refreshLibrary = false)
{
await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false);
return NoContent();
@@ -123,9 +122,9 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public ActionResult RenameVirtualFolder(
- [FromQuery] string name,
- [FromQuery] string newName,
- [FromQuery] bool refreshLibrary)
+ [FromQuery] string? name,
+ [FromQuery] string? newName,
+ [FromQuery] bool refreshLibrary = false)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -205,10 +204,10 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Paths")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult AddMediaPath(
- [FromQuery] string name,
- [FromQuery] string path,
- [FromQuery] MediaPathInfo pathInfo,
- [FromQuery] bool refreshLibrary)
+ [FromQuery] string? name,
+ [FromQuery] string? path,
+ [FromQuery] MediaPathInfo? pathInfo,
+ [FromQuery] bool refreshLibrary = false)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -256,8 +255,8 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Paths/Update")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateMediaPath(
- [FromQuery] string name,
- [FromQuery] MediaPathInfo pathInfo)
+ [FromQuery] string? name,
+ [FromQuery] MediaPathInfo? pathInfo)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -280,9 +279,9 @@ namespace Jellyfin.Api.Controllers
[HttpDelete("Paths")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult RemoveMediaPath(
- [FromQuery] string name,
- [FromQuery] string path,
- [FromQuery] bool refreshLibrary)
+ [FromQuery] string? name,
+ [FromQuery] string? path,
+ [FromQuery] bool refreshLibrary = false)
{
if (string.IsNullOrWhiteSpace(name))
{
@@ -327,8 +326,8 @@ namespace Jellyfin.Api.Controllers
[HttpPost("LibraryOptions")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateLibraryOptions(
- [FromQuery] string id,
- [FromQuery] LibraryOptions libraryOptions)
+ [FromQuery] string? id,
+ [FromQuery] LibraryOptions? libraryOptions)
{
var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(id);
diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs
new file mode 100644
index 000000000..bc5446510
--- /dev/null
+++ b/Jellyfin.Api/Controllers/LiveTvController.cs
@@ -0,0 +1,1236 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Net.Mime;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+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;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Net;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Live tv controller.
+ /// </summary>
+ public class LiveTvController : BaseJellyfinApiController
+ {
+ private readonly ILiveTvManager _liveTvManager;
+ private readonly IUserManager _userManager;
+ private readonly IHttpClient _httpClient;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IDtoService _dtoService;
+ private readonly ISessionContext _sessionContext;
+ private readonly IStreamHelper _streamHelper;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IConfigurationManager _configurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LiveTvController"/> class.
+ /// </summary>
+ /// <param name="liveTvManager">Instance of the <see cref="ILiveTvManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="httpClient">Instance of the <see cref="IHttpClient"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ /// <param name="sessionContext">Instance of the <see cref="ISessionContext"/> interface.</param>
+ /// <param name="streamHelper">Instance of the <see cref="IStreamHelper"/> interface.</param>
+ /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+ public LiveTvController(
+ ILiveTvManager liveTvManager,
+ IUserManager userManager,
+ IHttpClient httpClient,
+ ILibraryManager libraryManager,
+ IDtoService dtoService,
+ ISessionContext sessionContext,
+ IStreamHelper streamHelper,
+ IMediaSourceManager mediaSourceManager,
+ IConfigurationManager configurationManager)
+ {
+ _liveTvManager = liveTvManager;
+ _userManager = userManager;
+ _httpClient = httpClient;
+ _libraryManager = libraryManager;
+ _dtoService = dtoService;
+ _sessionContext = sessionContext;
+ _streamHelper = streamHelper;
+ _mediaSourceManager = mediaSourceManager;
+ _configurationManager = configurationManager;
+ }
+
+ /// <summary>
+ /// Gets available live tv services.
+ /// </summary>
+ /// <response code="200">Available live tv services returned.</response>
+ /// <returns>
+ /// An <see cref="OkResult"/> containing the available live tv services.
+ /// </returns>
+ [HttpGet("Info")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public ActionResult<LiveTvInfo> GetLiveTvInfo()
+ {
+ return _liveTvManager.GetLiveTvInfo(CancellationToken.None);
+ }
+
+ /// <summary>
+ /// Gets available live tv channels.
+ /// </summary>
+ /// <param name="type">Optional. Filter by channel type.</param>
+ /// <param name="userId">Optional. Filter by user and attach user data.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="isMovie">Optional. Filter for movies.</param>
+ /// <param name="isSeries">Optional. Filter for series.</param>
+ /// <param name="isNews">Optional. Filter for news.</param>
+ /// <param name="isKids">Optional. Filter for kids.</param>
+ /// <param name="isSports">Optional. Filter for sports.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="isFavorite">Optional. Filter by channels that are favorites, or not.</param>
+ /// <param name="isLiked">Optional. Filter by channels that are liked, or not.</param>
+ /// <param name="isDisliked">Optional. Filter by channels that are disliked, or not.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">"Optional. The image types to include in the output.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. 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="enableUserData">Optional. Include user data.</param>
+ /// <param name="sortBy">Optional. Key to sort by.</param>
+ /// <param name="sortOrder">Optional. Sort order.</param>
+ /// <param name="enableFavoriteSorting">Optional. Incorporate favorite and like status into channel sorting.</param>
+ /// <param name="addCurrentProgram">Optional. Adds current program info to each channel.</param>
+ /// <response code="200">Available live tv channels returned.</response>
+ /// <returns>
+ /// An <see cref="OkResult"/> containing the resulting available live tv channels.
+ /// </returns>
+ [HttpGet("Channels")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public ActionResult<QueryResult<BaseItemDto>> GetChannels(
+ [FromQuery] ChannelType? type,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? startIndex,
+ [FromQuery] bool? isMovie,
+ [FromQuery] bool? isSeries,
+ [FromQuery] bool? isNews,
+ [FromQuery] bool? isKids,
+ [FromQuery] bool? isSports,
+ [FromQuery] int? limit,
+ [FromQuery] bool? isFavorite,
+ [FromQuery] bool? isLiked,
+ [FromQuery] bool? isDisliked,
+ [FromQuery] bool? enableImages,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] string? sortBy,
+ [FromQuery] SortOrder? sortOrder,
+ [FromQuery] bool enableFavoriteSorting = false,
+ [FromQuery] bool addCurrentProgram = true)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+ var channelResult = _liveTvManager.GetInternalChannels(
+ new LiveTvChannelQuery
+ {
+ ChannelType = type,
+ UserId = userId ?? Guid.Empty,
+ StartIndex = startIndex,
+ Limit = limit,
+ IsFavorite = isFavorite,
+ IsLiked = isLiked,
+ IsDisliked = isDisliked,
+ EnableFavoriteSorting = enableFavoriteSorting,
+ IsMovie = isMovie,
+ IsSeries = isSeries,
+ IsNews = isNews,
+ IsKids = isKids,
+ IsSports = isSports,
+ SortBy = RequestHelpers.Split(sortBy, ',', true),
+ SortOrder = sortOrder ?? SortOrder.Ascending,
+ AddCurrentProgram = addCurrentProgram
+ },
+ dtoOptions,
+ CancellationToken.None);
+
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+
+ var fieldsList = dtoOptions.Fields.ToList();
+ fieldsList.Remove(ItemFields.CanDelete);
+ fieldsList.Remove(ItemFields.CanDownload);
+ fieldsList.Remove(ItemFields.DisplayPreferencesId);
+ fieldsList.Remove(ItemFields.Etag);
+ dtoOptions.Fields = fieldsList.ToArray();
+ dtoOptions.AddCurrentProgram = addCurrentProgram;
+
+ var returnArray = _dtoService.GetBaseItemDtos(channelResult.Items, dtoOptions, user);
+ return new QueryResult<BaseItemDto>
+ {
+ Items = returnArray,
+ TotalRecordCount = channelResult.TotalRecordCount
+ };
+ }
+
+ /// <summary>
+ /// Gets a live tv channel.
+ /// </summary>
+ /// <param name="channelId">Channel id.</param>
+ /// <param name="userId">Optional. Attach user data.</param>
+ /// <response code="200">Live tv channel returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns>
+ [HttpGet("Channels/{channelId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public ActionResult<BaseItemDto> GetChannel([FromRoute] Guid channelId, [FromQuery] Guid? userId)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var item = channelId.Equals(Guid.Empty)
+ ? _libraryManager.GetUserRootFolder()
+ : _libraryManager.GetItemById(channelId);
+
+ var dtoOptions = new DtoOptions()
+ .AddClientFields(Request);
+ return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+ }
+
+ /// <summary>
+ /// Gets live tv recordings.
+ /// </summary>
+ /// <param name="channelId">Optional. Filter by channel id.</param>
+ /// <param name="userId">Optional. Filter by user and attach user data.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="status">Optional. Filter by recording status.</param>
+ /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param>
+ /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. 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="enableUserData">Optional. Include user data.</param>
+ /// <param name="isMovie">Optional. Filter for movies.</param>
+ /// <param name="isSeries">Optional. Filter for series.</param>
+ /// <param name="isKids">Optional. Filter for kids.</param>
+ /// <param name="isSports">Optional. Filter for sports.</param>
+ /// <param name="isNews">Optional. Filter for news.</param>
+ /// <param name="isLibraryItem">Optional. Filter for is library item.</param>
+ /// <param name="enableTotalRecordCount">Optional. Return total record count.</param>
+ /// <response code="200">Live tv recordings returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns>
+ [HttpGet("Recordings")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public ActionResult<QueryResult<BaseItemDto>> GetRecordings(
+ [FromQuery] string? channelId,
+ [FromQuery] Guid? userId,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] RecordingStatus? status,
+ [FromQuery] bool? isInProgress,
+ [FromQuery] string? seriesTimerId,
+ [FromQuery] bool? enableImages,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] bool? isMovie,
+ [FromQuery] bool? isSeries,
+ [FromQuery] bool? isKids,
+ [FromQuery] bool? isSports,
+ [FromQuery] bool? isNews,
+ [FromQuery] bool? isLibraryItem,
+ [FromQuery] bool enableTotalRecordCount = true)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+ return _liveTvManager.GetRecordings(
+ new RecordingQuery
+ {
+ ChannelId = channelId,
+ UserId = userId ?? Guid.Empty,
+ StartIndex = startIndex,
+ Limit = limit,
+ Status = status,
+ SeriesTimerId = seriesTimerId,
+ IsInProgress = isInProgress,
+ EnableTotalRecordCount = enableTotalRecordCount,
+ IsMovie = isMovie,
+ IsNews = isNews,
+ IsSeries = isSeries,
+ IsKids = isKids,
+ IsSports = isSports,
+ IsLibraryItem = isLibraryItem,
+ Fields = RequestHelpers.GetItemFields(fields),
+ ImageTypeLimit = imageTypeLimit,
+ EnableImages = enableImages
+ }, dtoOptions);
+ }
+
+ /// <summary>
+ /// Gets live tv recording series.
+ /// </summary>
+ /// <param name="channelId">Optional. Filter by channel id.</param>
+ /// <param name="userId">Optional. Filter by user and attach user data.</param>
+ /// <param name="groupId">Optional. Filter by recording group.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="status">Optional. Filter by recording status.</param>
+ /// <param name="isInProgress">Optional. Filter by recordings that are in progress, or not.</param>
+ /// <param name="seriesTimerId">Optional. Filter by recordings belonging to a series timer.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. 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="enableUserData">Optional. Include user data.</param>
+ /// <param name="enableTotalRecordCount">Optional. Return total record count.</param>
+ /// <response code="200">Live tv recordings returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the live tv recordings.</returns>
+ [HttpGet("Recordings/Series")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [Obsolete("This endpoint is obsolete.")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")]
+ public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries(
+ [FromQuery] string? channelId,
+ [FromQuery] Guid? userId,
+ [FromQuery] string? groupId,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] RecordingStatus? status,
+ [FromQuery] bool? isInProgress,
+ [FromQuery] string? seriesTimerId,
+ [FromQuery] bool? enableImages,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] bool enableTotalRecordCount = true)
+ {
+ return new QueryResult<BaseItemDto>();
+ }
+
+ /// <summary>
+ /// Gets live tv recording groups.
+ /// </summary>
+ /// <param name="userId">Optional. Filter by user and attach user data.</param>
+ /// <response code="200">Recording groups returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the recording groups.</returns>
+ [HttpGet("Recordings/Groups")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [Obsolete("This endpoint is obsolete.")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
+ public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId)
+ {
+ return new QueryResult<BaseItemDto>();
+ }
+
+ /// <summary>
+ /// Gets recording folders.
+ /// </summary>
+ /// <param name="userId">Optional. Filter by user and attach user data.</param>
+ /// <response code="200">Recording folders returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the recording folders.</returns>
+ [HttpGet("Recordings/Folders")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public ActionResult<QueryResult<BaseItemDto>> GetRecordingFolders([FromQuery] Guid? userId)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var folders = _liveTvManager.GetRecordingFolders(user);
+
+ var returnArray = _dtoService.GetBaseItemDtos(folders, new DtoOptions(), user);
+
+ return new QueryResult<BaseItemDto>
+ {
+ Items = returnArray,
+ TotalRecordCount = returnArray.Count
+ };
+ }
+
+ /// <summary>
+ /// Gets a live tv recording.
+ /// </summary>
+ /// <param name="recordingId">Recording id.</param>
+ /// <param name="userId">Optional. Attach user data.</param>
+ /// <response code="200">Recording returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns>
+ [HttpGet("Recordings/{recordingId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public ActionResult<BaseItemDto> GetRecording([FromRoute] Guid recordingId, [FromQuery] Guid? userId)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var item = recordingId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId);
+
+ var dtoOptions = new DtoOptions()
+ .AddClientFields(Request);
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+ }
+
+ /// <summary>
+ /// Resets a tv tuner.
+ /// </summary>
+ /// <param name="tunerId">Tuner id.</param>
+ /// <response code="204">Tuner reset.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("Tuners/{tunerId}/Reset")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public ActionResult ResetTuner([FromRoute] string tunerId)
+ {
+ AssertUserCanManageLiveTv();
+ _liveTvManager.ResetTuner(tunerId, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Gets a timer.
+ /// </summary>
+ /// <param name="timerId">Timer id.</param>
+ /// <response code="200">Timer returned.</response>
+ /// <returns>
+ /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the timer.
+ /// </returns>
+ [HttpGet("Timers/{timerId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public async Task<ActionResult<TimerInfoDto>> GetTimer(string timerId)
+ {
+ return await _liveTvManager.GetTimer(timerId, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets the default values for a new timer.
+ /// </summary>
+ /// <param name="programId">Optional. To attach default values based on a program.</param>
+ /// <response code="200">Default values returned.</response>
+ /// <returns>
+ /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the default values for a timer.
+ /// </returns>
+ [HttpGet("Timers/Defaults")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public async Task<ActionResult<SeriesTimerInfoDto>> GetDefaultTimer([FromQuery] string? programId)
+ {
+ return string.IsNullOrEmpty(programId)
+ ? await _liveTvManager.GetNewTimerDefaults(CancellationToken.None).ConfigureAwait(false)
+ : await _liveTvManager.GetNewTimerDefaults(programId, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets the live tv timers.
+ /// </summary>
+ /// <param name="channelId">Optional. Filter by channel id.</param>
+ /// <param name="seriesTimerId">Optional. Filter by timers belonging to a series timer.</param>
+ /// <param name="isActive">Optional. Filter by timers that are active.</param>
+ /// <param name="isScheduled">Optional. Filter by timers that are scheduled.</param>
+ /// <returns>
+ /// A <see cref="Task"/> containing an <see cref="OkResult"/> which contains the live tv timers.
+ /// </returns>
+ [HttpGet("Timers")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public async Task<ActionResult<QueryResult<TimerInfoDto>>> GetTimers(
+ [FromQuery] string? channelId,
+ [FromQuery] string? seriesTimerId,
+ [FromQuery] bool? isActive,
+ [FromQuery] bool? isScheduled)
+ {
+ return await _liveTvManager.GetTimers(
+ new TimerQuery
+ {
+ ChannelId = channelId,
+ SeriesTimerId = seriesTimerId,
+ IsActive = isActive,
+ IsScheduled = isScheduled
+ }, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets available live tv epgs.
+ /// </summary>
+ /// <param name="channelIds">The channels to return guide information for.</param>
+ /// <param name="userId">Optional. Filter by user id.</param>
+ /// <param name="minStartDate">Optional. The minimum premiere start date.</param>
+ /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param>
+ /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param>
+ /// <param name="maxStartDate">Optional. The maximum premiere start date.</param>
+ /// <param name="minEndDate">Optional. The minimum premiere end date.</param>
+ /// <param name="maxEndDate">Optional. The maximum premiere end date.</param>
+ /// <param name="isMovie">Optional. Filter for movies.</param>
+ /// <param name="isSeries">Optional. Filter for series.</param>
+ /// <param name="isNews">Optional. Filter for news.</param>
+ /// <param name="isKids">Optional. Filter for kids.</param>
+ /// <param name="isSports">Optional. Filter for sports.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Name, StartDate.</param>
+ /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+ /// <param name="genres">The genres to return guide information for.</param>
+ /// <param name="genreIds">The genre ids to return guide information for.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="seriesTimerId">Optional. Filter by series timer id.</param>
+ /// <param name="librarySeriesId">Optional. Filter by library series id.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. 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="enableTotalRecordCount">Retrieve total record count.</param>
+ /// <response code="200">Live tv epgs returned.</response>
+ /// <returns>
+ /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs.
+ /// </returns>
+ [HttpGet("Programs")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms(
+ [FromQuery] string? channelIds,
+ [FromQuery] Guid? userId,
+ [FromQuery] DateTime? minStartDate,
+ [FromQuery] bool? hasAired,
+ [FromQuery] bool? isAiring,
+ [FromQuery] DateTime? maxStartDate,
+ [FromQuery] DateTime? minEndDate,
+ [FromQuery] DateTime? maxEndDate,
+ [FromQuery] bool? isMovie,
+ [FromQuery] bool? isSeries,
+ [FromQuery] bool? isNews,
+ [FromQuery] bool? isKids,
+ [FromQuery] bool? isSports,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] string? sortBy,
+ [FromQuery] string? sortOrder,
+ [FromQuery] string? genres,
+ [FromQuery] string? genreIds,
+ [FromQuery] bool? enableImages,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] string? seriesTimerId,
+ [FromQuery] Guid? librarySeriesId,
+ [FromQuery] string? fields,
+ [FromQuery] bool enableTotalRecordCount = true)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+
+ var query = new InternalItemsQuery(user)
+ {
+ ChannelIds = RequestHelpers.Split(channelIds, ',', true)
+ .Select(i => new Guid(i)).ToArray(),
+ HasAired = hasAired,
+ IsAiring = isAiring,
+ EnableTotalRecordCount = enableTotalRecordCount,
+ MinStartDate = minStartDate,
+ MinEndDate = minEndDate,
+ MaxStartDate = maxStartDate,
+ MaxEndDate = maxEndDate,
+ StartIndex = startIndex,
+ Limit = limit,
+ OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+ IsNews = isNews,
+ IsMovie = isMovie,
+ IsSeries = isSeries,
+ IsKids = isKids,
+ IsSports = isSports,
+ SeriesTimerId = seriesTimerId,
+ Genres = RequestHelpers.Split(genres, ',', true),
+ GenreIds = RequestHelpers.GetGuids(genreIds)
+ };
+
+ if (!librarySeriesId.Equals(Guid.Empty))
+ {
+ query.IsSeries = true;
+
+ if (_libraryManager.GetItemById(librarySeriesId ?? Guid.Empty) is Series series)
+ {
+ query.Name = series.Name;
+ }
+ }
+
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets available live tv epgs.
+ /// </summary>
+ /// <param name="body">Request body.</param>
+ /// <response code="200">Live tv epgs returned.</response>
+ /// <returns>
+ /// A <see cref="Task"/> containing a <see cref="OkResult"/> which contains the live tv epgs.
+ /// </returns>
+ [HttpPost("Programs")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public async Task<ActionResult<QueryResult<BaseItemDto>>> GetPrograms([FromBody] GetProgramsDto body)
+ {
+ var user = body.UserId.Equals(Guid.Empty) ? null : _userManager.GetUserById(body.UserId);
+
+ var query = new InternalItemsQuery(user)
+ {
+ ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true)
+ .Select(i => new Guid(i)).ToArray(),
+ HasAired = body.HasAired,
+ IsAiring = body.IsAiring,
+ EnableTotalRecordCount = body.EnableTotalRecordCount,
+ MinStartDate = body.MinStartDate,
+ MinEndDate = body.MinEndDate,
+ MaxStartDate = body.MaxStartDate,
+ MaxEndDate = body.MaxEndDate,
+ StartIndex = body.StartIndex,
+ Limit = body.Limit,
+ OrderBy = RequestHelpers.GetOrderBy(body.SortBy, body.SortOrder),
+ IsNews = body.IsNews,
+ IsMovie = body.IsMovie,
+ IsSeries = body.IsSeries,
+ IsKids = body.IsKids,
+ IsSports = body.IsSports,
+ SeriesTimerId = body.SeriesTimerId,
+ Genres = RequestHelpers.Split(body.Genres, ',', true),
+ GenreIds = RequestHelpers.GetGuids(body.GenreIds)
+ };
+
+ if (!body.LibrarySeriesId.Equals(Guid.Empty))
+ {
+ query.IsSeries = true;
+
+ if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series)
+ {
+ query.Name = series.Name;
+ }
+ }
+
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(body.Fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes);
+ return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets recommended live tv epgs.
+ /// </summary>
+ /// <param name="userId">Optional. filter by user id.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="isAiring">Optional. Filter by programs that are currently airing, or not.</param>
+ /// <param name="hasAired">Optional. Filter by programs that have completed airing, or not.</param>
+ /// <param name="isSeries">Optional. Filter for series.</param>
+ /// <param name="isMovie">Optional. Filter for movies.</param>
+ /// <param name="isNews">Optional. Filter for news.</param>
+ /// <param name="isKids">Optional. Filter for kids.</param>
+ /// <param name="isSports">Optional. Filter for sports.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="genreIds">The genres to return guide information for.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. 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="enableUserData">Optional. include user data.</param>
+ /// <param name="enableTotalRecordCount">Retrieve total record count.</param>
+ /// <response code="200">Recommended epgs returned.</response>
+ /// <returns>A <see cref="OkResult"/> containing the queryresult of recommended epgs.</returns>
+ [HttpGet("Programs/Recommended")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetRecommendedPrograms(
+ [FromQuery] Guid? userId,
+ [FromQuery] int? limit,
+ [FromQuery] bool? isAiring,
+ [FromQuery] bool? hasAired,
+ [FromQuery] bool? isSeries,
+ [FromQuery] bool? isMovie,
+ [FromQuery] bool? isNews,
+ [FromQuery] bool? isKids,
+ [FromQuery] bool? isSports,
+ [FromQuery] bool? enableImages,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes,
+ [FromQuery] string? genreIds,
+ [FromQuery] string? fields,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] bool enableTotalRecordCount = true)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+
+ var query = new InternalItemsQuery(user)
+ {
+ IsAiring = isAiring,
+ Limit = limit,
+ HasAired = hasAired,
+ IsSeries = isSeries,
+ IsMovie = isMovie,
+ IsKids = isKids,
+ IsNews = isNews,
+ IsSports = isSports,
+ EnableTotalRecordCount = enableTotalRecordCount,
+ GenreIds = RequestHelpers.GetGuids(genreIds)
+ };
+
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ return _liveTvManager.GetRecommendedPrograms(query, dtoOptions, CancellationToken.None);
+ }
+
+ /// <summary>
+ /// Gets a live tv program.
+ /// </summary>
+ /// <param name="programId">Program id.</param>
+ /// <param name="userId">Optional. Attach user data.</param>
+ /// <response code="200">Program returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the livetv program.</returns>
+ [HttpGet("Programs/{programId}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<BaseItemDto>> GetProgram(
+ [FromRoute] string programId,
+ [FromQuery] Guid? userId)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+
+ return await _liveTvManager.GetProgram(programId, CancellationToken.None, user).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Deletes a live tv recording.
+ /// </summary>
+ /// <param name="recordingId">Recording id.</param>
+ /// <response code="204">Recording deleted.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+ [HttpDelete("Recordings/{recordingId}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult DeleteRecording([FromRoute] Guid recordingId)
+ {
+ AssertUserCanManageLiveTv();
+
+ var item = _libraryManager.GetItemById(recordingId);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ _libraryManager.DeleteItem(item, new DeleteOptions
+ {
+ DeleteFileLocation = false
+ });
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Cancels a live tv timer.
+ /// </summary>
+ /// <param name="timerId">Timer id.</param>
+ /// <response code="204">Timer deleted.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("Timers/{timerId}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> CancelTimer([FromRoute] string timerId)
+ {
+ AssertUserCanManageLiveTv();
+ await _liveTvManager.CancelTimer(timerId).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Updates a live tv timer.
+ /// </summary>
+ /// <param name="timerId">Timer id.</param>
+ /// <param name="timerInfo">New timer info.</param>
+ /// <response code="204">Timer updated.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("Timers/{timerId}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
+ public async Task<ActionResult> UpdateTimer([FromRoute] string timerId, [FromBody] TimerInfoDto timerInfo)
+ {
+ AssertUserCanManageLiveTv();
+ await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Creates a live tv timer.
+ /// </summary>
+ /// <param name="timerInfo">New timer info.</param>
+ /// <response code="204">Timer created.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("Timers")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> CreateTimer([FromBody] TimerInfoDto timerInfo)
+ {
+ AssertUserCanManageLiveTv();
+ await _liveTvManager.CreateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Gets a live tv series timer.
+ /// </summary>
+ /// <param name="timerId">Timer id.</param>
+ /// <response code="200">Series timer returned.</response>
+ /// <response code="404">Series timer not found.</response>
+ /// <returns>A <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if timer not found.</returns>
+ [HttpGet("SeriesTimers/{timerId}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult<SeriesTimerInfoDto>> GetSeriesTimer([FromRoute] string timerId)
+ {
+ var timer = await _liveTvManager.GetSeriesTimer(timerId, CancellationToken.None).ConfigureAwait(false);
+ if (timer == null)
+ {
+ return NotFound();
+ }
+
+ return timer;
+ }
+
+ /// <summary>
+ /// Gets live tv series timers.
+ /// </summary>
+ /// <param name="sortBy">Optional. Sort by SortName or Priority.</param>
+ /// <param name="sortOrder">Optional. Sort in Ascending or Descending order.</param>
+ /// <response code="200">Timers returned.</response>
+ /// <returns>An <see cref="OkResult"/> of live tv series timers.</returns>
+ [HttpGet("SeriesTimers")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<QueryResult<SeriesTimerInfoDto>>> GetSeriesTimers([FromQuery] string? sortBy, [FromQuery] SortOrder? sortOrder)
+ {
+ return await _liveTvManager.GetSeriesTimers(
+ new SeriesTimerQuery
+ {
+ SortOrder = sortOrder ?? SortOrder.Ascending,
+ SortBy = sortBy
+ }, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Cancels a live tv series timer.
+ /// </summary>
+ /// <param name="timerId">Timer id.</param>
+ /// <response code="204">Timer cancelled.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("SeriesTimers/{timerId}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> CancelSeriesTimer([FromRoute] string timerId)
+ {
+ AssertUserCanManageLiveTv();
+ await _liveTvManager.CancelSeriesTimer(timerId).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Updates a live tv series timer.
+ /// </summary>
+ /// <param name="timerId">Timer id.</param>
+ /// <param name="seriesTimerInfo">New series timer info.</param>
+ /// <response code="204">Series timer updated.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("SeriesTimers/{timerId}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
+ public async Task<ActionResult> UpdateSeriesTimer([FromRoute] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
+ {
+ AssertUserCanManageLiveTv();
+ await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Creates a live tv series timer.
+ /// </summary>
+ /// <param name="seriesTimerInfo">New series timer info.</param>
+ /// <response code="204">Series timer info created.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("SeriesTimers")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> CreateSeriesTimer([FromBody] SeriesTimerInfoDto seriesTimerInfo)
+ {
+ AssertUserCanManageLiveTv();
+ await _liveTvManager.CreateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Get recording group.
+ /// </summary>
+ /// <param name="groupId">Group id.</param>
+ /// <returns>A <see cref="NotFoundResult"/>.</returns>
+ [HttpGet("Recordings/Groups/{groupId}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [Obsolete("This endpoint is obsolete.")]
+ public ActionResult<BaseItemDto> GetRecordingGroup([FromQuery] Guid? groupId)
+ {
+ return NotFound();
+ }
+
+ /// <summary>
+ /// Get guid info.
+ /// </summary>
+ /// <response code="200">Guid info returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the guide info.</returns>
+ [HttpGet("GuideInfo")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<GuideInfo> GetGuideInfo()
+ {
+ return _liveTvManager.GetGuideInfo();
+ }
+
+ /// <summary>
+ /// Adds a tuner host.
+ /// </summary>
+ /// <param name="tunerHostInfo">New tuner host.</param>
+ /// <response code="200">Created tuner host returned.</response>
+ /// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
+ [HttpPost("TunerHosts")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
+ {
+ return await _liveTvManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Deletes a tuner host.
+ /// </summary>
+ /// <param name="id">Tuner host id.</param>
+ /// <response code="204">Tuner host deleted.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("TunerHosts")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult DeleteTunerHost([FromQuery] string? id)
+ {
+ var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
+ config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
+ _configurationManager.SaveConfiguration("livetv", config);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Gets default listings provider info.
+ /// </summary>
+ /// <response code="200">Default listings provider info returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the default listings provider info.</returns>
+ [HttpGet("ListingProviders/Default")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<ListingsProviderInfo> GetDefaultListingProvider()
+ {
+ return new ListingsProviderInfo();
+ }
+
+ /// <summary>
+ /// Adds a listings provider.
+ /// </summary>
+ /// <param name="pw">Password.</param>
+ /// <param name="listingsProviderInfo">New listings info.</param>
+ /// <param name="validateListings">Validate listings.</param>
+ /// <param name="validateLogin">Validate login.</param>
+ /// <response code="200">Created listings provider returned.</response>
+ /// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
+ [HttpGet("ListingProviders")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
+ public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
+ [FromQuery] string? pw,
+ [FromBody] ListingsProviderInfo listingsProviderInfo,
+ [FromQuery] bool validateListings = false,
+ [FromQuery] bool validateLogin = false)
+ {
+ using var sha = SHA1.Create();
+ if (!string.IsNullOrEmpty(pw))
+ {
+ listingsProviderInfo.Password = Hex.Encode(sha.ComputeHash(Encoding.UTF8.GetBytes(pw)));
+ }
+
+ return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Delete listing provider.
+ /// </summary>
+ /// <param name="id">Listing provider id.</param>
+ /// <response code="204">Listing provider deleted.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("ListingProviders")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult DeleteListingProvider([FromQuery] string? id)
+ {
+ _liveTvManager.DeleteListingsProvider(id);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Gets available lineups.
+ /// </summary>
+ /// <param name="id">Provider id.</param>
+ /// <param name="type">Provider type.</param>
+ /// <param name="location">Location.</param>
+ /// <param name="country">Country.</param>
+ /// <response code="200">Available lineups returned.</response>
+ /// <returns>A <see cref="OkResult"/> containing the available lineups.</returns>
+ [HttpGet("ListingProviders/Lineups")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<IEnumerable<NameIdPair>>> GetLineups(
+ [FromQuery] string? id,
+ [FromQuery] string? type,
+ [FromQuery] string? location,
+ [FromQuery] string? country)
+ {
+ return await _liveTvManager.GetLineups(type, id, country, location).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets available countries.
+ /// </summary>
+ /// <response code="200">Available countries returned.</response>
+ /// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
+ [HttpGet("ListingProviders/SchedulesDirect/Countries")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult> GetSchedulesDirectCountries()
+ {
+ // https://json.schedulesdirect.org/20141201/available/countries
+ var response = await _httpClient.Get(new HttpRequestOptions
+ {
+ Url = "https://json.schedulesdirect.org/20141201/available/countries",
+ BufferContent = false
+ }).ConfigureAwait(false);
+ return File(response, MediaTypeNames.Application.Json);
+ }
+
+ /// <summary>
+ /// Get channel mapping options.
+ /// </summary>
+ /// <param name="providerId">Provider id.</param>
+ /// <response code="200">Channel mapping options returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
+ [HttpGet("ChannelMappingOptions")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<ChannelMappingOptionsDto>> GetChannelMappingOptions([FromQuery] string? providerId)
+ {
+ var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
+
+ var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase));
+
+ var listingsProviderName = _liveTvManager.ListingProviders.First(i => string.Equals(i.Type, listingsProviderInfo.Type, StringComparison.OrdinalIgnoreCase)).Name;
+
+ var tunerChannels = await _liveTvManager.GetChannelsForListingsProvider(providerId, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var providerChannels = await _liveTvManager.GetChannelsFromListingsProviderData(providerId, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var mappings = listingsProviderInfo.ChannelMappings;
+
+ return new ChannelMappingOptionsDto
+ {
+ TunerChannels = tunerChannels.Select(i => _liveTvManager.GetTunerChannelMapping(i, mappings, providerChannels)).ToList(),
+ ProviderChannels = providerChannels.Select(i => new NameIdPair
+ {
+ Name = i.Name,
+ Id = i.Id
+ }).ToList(),
+ Mappings = mappings,
+ ProviderName = listingsProviderName
+ };
+ }
+
+ /// <summary>
+ /// Set channel mappings.
+ /// </summary>
+ /// <param name="providerId">Provider id.</param>
+ /// <param name="tunerChannelId">Tuner channel id.</param>
+ /// <param name="providerChannelId">Provider channel id.</param>
+ /// <response code="200">Created channel mapping returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
+ [HttpPost("ChannelMappings")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<TunerChannelMapping>> SetChannelMapping(
+ [FromQuery] string? providerId,
+ [FromQuery] string? tunerChannelId,
+ [FromQuery] string? providerChannelId)
+ {
+ return await _liveTvManager.SetChannelMapping(providerId, tunerChannelId, providerChannelId).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Get tuner host types.
+ /// </summary>
+ /// <response code="200">Tuner host types returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the tuner host types.</returns>
+ [HttpGet("TunerHosts/Types")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<NameIdPair>> GetTunerHostTypes()
+ {
+ return _liveTvManager.GetTunerHostTypes();
+ }
+
+ /// <summary>
+ /// Discover tuners.
+ /// </summary>
+ /// <param name="newDevicesOnly">Only discover new tuners.</param>
+ /// <response code="200">Tuners returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
+ [HttpGet("Tuners/Discvover")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
+ {
+ return await _liveTvManager.DiscoverTuners(newDevicesOnly, CancellationToken.None).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets a live tv recording stream.
+ /// </summary>
+ /// <param name="recordingId">Recording id.</param>
+ /// <response code="200">Recording stream returned.</response>
+ /// <response code="404">Recording not found.</response>
+ /// <returns>
+ /// An <see cref="OkResult"/> containing the recording stream on success,
+ /// or a <see cref="NotFoundResult"/> if recording not found.
+ /// </returns>
+ [HttpGet("LiveRecordings/{recordingId}/stream")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> GetLiveRecordingFile([FromRoute] string recordingId)
+ {
+ var path = _liveTvManager.GetEmbyTvActiveRecordingPath(recordingId);
+
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return NotFound();
+ }
+
+ await using var memoryStream = new MemoryStream();
+ await new ProgressiveFileCopier(_streamHelper, path).WriteToAsync(memoryStream, CancellationToken.None).ConfigureAwait(false);
+ return File(memoryStream, MimeTypes.GetMimeType(path));
+ }
+
+ /// <summary>
+ /// Gets a live tv channel stream.
+ /// </summary>
+ /// <param name="streamId">Stream id.</param>
+ /// <param name="container">Container type.</param>
+ /// <response code="200">Stream returned.</response>
+ /// <response code="404">Stream not found.</response>
+ /// <returns>
+ /// An <see cref="OkResult"/> containing the channel stream on success,
+ /// or a <see cref="NotFoundResult"/> if stream not found.
+ /// </returns>
+ [HttpGet("LiveStreamFiles/{streamId}/stream.{container}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> GetLiveStreamFile([FromRoute] string streamId, [FromRoute] string container)
+ {
+ var liveStreamInfo = await _mediaSourceManager.GetDirectStreamProviderByUniqueId(streamId, CancellationToken.None).ConfigureAwait(false);
+ if (liveStreamInfo == null)
+ {
+ return NotFound();
+ }
+
+ await using var memoryStream = new MemoryStream();
+ await new ProgressiveFileCopier(_streamHelper, liveStreamInfo).WriteToAsync(memoryStream, CancellationToken.None).ConfigureAwait(false);
+ return File(memoryStream, MimeTypes.GetMimeType("file." + container));
+ }
+
+ private void AssertUserCanManageLiveTv()
+ {
+ var user = _sessionContext.GetUser(Request);
+
+ if (user == null)
+ {
+ throw new SecurityException("Anonymous live tv management is not allowed.");
+ }
+
+ if (!user.HasPermission(PermissionKind.EnableLiveTvManagement))
+ {
+ throw new SecurityException("The current user does not have permission to manage live tv.");
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs
new file mode 100644
index 000000000..da400f510
--- /dev/null
+++ b/Jellyfin.Api/Controllers/MediaInfoController.cs
@@ -0,0 +1,775 @@
+using System;
+using System.Buffers;
+using System.Globalization;
+using System.Linq;
+using System.Net.Mime;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+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;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// The media info controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class MediaInfoController : BaseJellyfinApiController
+ {
+ 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 _logger;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MediaInfoController"/> class.
+ /// </summary>
+ /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="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>
+ public MediaInfoController(
+ IMediaSourceManager mediaSourceManager,
+ IDeviceManager deviceManager,
+ ILibraryManager libraryManager,
+ INetworkManager networkManager,
+ IMediaEncoder mediaEncoder,
+ IUserManager userManager,
+ IAuthorizationContext authContext,
+ ILogger<MediaInfoController> logger,
+ IServerConfigurationManager serverConfigurationManager)
+ {
+ _mediaSourceManager = mediaSourceManager;
+ _deviceManager = deviceManager;
+ _libraryManager = libraryManager;
+ _networkManager = networkManager;
+ _mediaEncoder = mediaEncoder;
+ _userManager = userManager;
+ _authContext = authContext;
+ _logger = logger;
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <summary>
+ /// Gets live playback media info for an item.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="userId">The user id.</param>
+ /// <response code="200">Playback info returned.</response>
+ /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns>
+ [HttpGet("/Items/{itemId}/PlaybackInfo")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute] Guid itemId, [FromQuery] Guid? userId)
+ {
+ return await GetPlaybackInfoInternal(itemId, userId, null, null).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets live playback media info for an item.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="userId">The user id.</param>
+ /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
+ /// <param name="startTimeTicks">The start time in ticks.</param>
+ /// <param name="audioStreamIndex">The audio stream index.</param>
+ /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+ /// <param name="maxAudioChannels">The maximum number of audio channels.</param>
+ /// <param name="mediaSourceId">The media source id.</param>
+ /// <param name="liveStreamId">The livestream id.</param>
+ /// <param name="deviceProfile">The device profile.</param>
+ /// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param>
+ /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
+ /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
+ /// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param>
+ /// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param>
+ /// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param>
+ /// <response code="200">Playback info returned.</response>
+ /// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns>
+ [HttpPost("/Items/{itemId}/PlaybackInfo")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo(
+ [FromRoute] Guid itemId,
+ [FromQuery] Guid? userId,
+ [FromQuery] long? maxStreamingBitrate,
+ [FromQuery] long? startTimeTicks,
+ [FromQuery] int? audioStreamIndex,
+ [FromQuery] int? subtitleStreamIndex,
+ [FromQuery] int? maxAudioChannels,
+ [FromQuery] string? mediaSourceId,
+ [FromQuery] string? liveStreamId,
+ [FromQuery] DeviceProfile? deviceProfile,
+ [FromQuery] bool autoOpenLiveStream = false,
+ [FromQuery] bool enableDirectPlay = true,
+ [FromQuery] bool enableDirectStream = true,
+ [FromQuery] bool enableTranscoding = true,
+ [FromQuery] bool allowVideoStreamCopy = true,
+ [FromQuery] bool allowAudioStreamCopy = true)
+ {
+ var authInfo = _authContext.GetAuthorizationInfo(Request);
+
+ var profile = deviceProfile;
+
+ _logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile);
+
+ if (profile == null)
+ {
+ var caps = _deviceManager.GetCapabilities(authInfo.DeviceId);
+ if (caps != null)
+ {
+ profile = caps.DeviceProfile;
+ }
+ }
+
+ var info = await GetPlaybackInfoInternal(itemId, userId, mediaSourceId, liveStreamId).ConfigureAwait(false);
+
+ if (profile != null)
+ {
+ // set device specific data
+ var item = _libraryManager.GetItemById(itemId);
+
+ foreach (var mediaSource in info.MediaSources)
+ {
+ SetDeviceSpecificData(
+ item,
+ mediaSource,
+ profile,
+ authInfo,
+ maxStreamingBitrate ?? profile.MaxStreamingBitrate,
+ startTimeTicks ?? 0,
+ mediaSourceId ?? string.Empty,
+ audioStreamIndex,
+ subtitleStreamIndex,
+ maxAudioChannels,
+ info!.PlaySessionId!,
+ userId ?? Guid.Empty,
+ enableDirectPlay,
+ enableDirectStream,
+ enableTranscoding,
+ allowVideoStreamCopy,
+ allowAudioStreamCopy);
+ }
+
+ SortMediaSources(info, maxStreamingBitrate);
+ }
+
+ if (autoOpenLiveStream)
+ {
+ var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal));
+
+ if (mediaSource != null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId))
+ {
+ var openStreamResult = await OpenMediaSource(new LiveStreamRequest
+ {
+ AudioStreamIndex = audioStreamIndex,
+ 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 };
+ }
+ }
+
+ if (info.MediaSources != null)
+ {
+ foreach (var mediaSource in info.MediaSources)
+ {
+ NormalizeMediaSourceContainer(mediaSource, profile!, DlnaProfileType.Video);
+ }
+ }
+
+ return info;
+ }
+
+ /// <summary>
+ /// Opens a media source.
+ /// </summary>
+ /// <param name="openToken">The open token.</param>
+ /// <param name="userId">The user id.</param>
+ /// <param name="playSessionId">The play session id.</param>
+ /// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
+ /// <param name="startTimeTicks">The start time in ticks.</param>
+ /// <param name="audioStreamIndex">The audio stream index.</param>
+ /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+ /// <param name="maxAudioChannels">The maximum number of audio channels.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="deviceProfile">The device profile.</param>
+ /// <param name="directPlayProtocols">The direct play protocols. Default: <see cref="MediaProtocol.Http"/>.</param>
+ /// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
+ /// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
+ /// <response code="200">Media source opened.</response>
+ /// <returns>A <see cref="Task"/> containing a <see cref="LiveStreamResponse"/>.</returns>
+ [HttpPost("/LiveStreams/Open")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<LiveStreamResponse>> OpenLiveStream(
+ [FromQuery] string? openToken,
+ [FromQuery] Guid? userId,
+ [FromQuery] string? playSessionId,
+ [FromQuery] long? maxStreamingBitrate,
+ [FromQuery] long? startTimeTicks,
+ [FromQuery] int? audioStreamIndex,
+ [FromQuery] int? subtitleStreamIndex,
+ [FromQuery] int? maxAudioChannels,
+ [FromQuery] Guid? itemId,
+ [FromQuery] DeviceProfile? deviceProfile,
+ [FromQuery] MediaProtocol[] directPlayProtocols,
+ [FromQuery] bool enableDirectPlay = true,
+ [FromQuery] bool enableDirectStream = true)
+ {
+ var request = new LiveStreamRequest
+ {
+ OpenToken = openToken,
+ UserId = userId ?? Guid.Empty,
+ PlaySessionId = playSessionId,
+ MaxStreamingBitrate = maxStreamingBitrate,
+ StartTimeTicks = startTimeTicks,
+ AudioStreamIndex = audioStreamIndex,
+ SubtitleStreamIndex = subtitleStreamIndex,
+ MaxAudioChannels = maxAudioChannels,
+ ItemId = itemId ?? Guid.Empty,
+ DeviceProfile = deviceProfile,
+ EnableDirectPlay = enableDirectPlay,
+ EnableDirectStream = enableDirectStream,
+ DirectPlayProtocols = directPlayProtocols ?? new[] { MediaProtocol.Http }
+ };
+ return await OpenMediaSource(request).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Closes a media source.
+ /// </summary>
+ /// <param name="liveStreamId">The livestream id.</param>
+ /// <response code="204">Livestream closed.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("/LiveStreams/Close")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult CloseLiveStream([FromQuery] string? liveStreamId)
+ {
+ _mediaSourceManager.CloseLiveStream(liveStreamId).GetAwaiter().GetResult();
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Tests the network with a request with the size of the bitrate.
+ /// </summary>
+ /// <param name="size">The bitrate. Defaults to 102400.</param>
+ /// <response code="200">Test buffer returned.</response>
+ /// <response code="400">Size has to be a numer between 0 and 10,000,000.</response>
+ /// <returns>A <see cref="FileResult"/> with specified bitrate.</returns>
+ [HttpGet("/Playback/BitrateTest")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [Produces(MediaTypeNames.Application.Octet)]
+ public ActionResult GetBitrateTestBytes([FromQuery] int size = 102400)
+ {
+ const int MaxSize = 10_000_000;
+
+ if (size <= 0)
+ {
+ return BadRequest($"The requested size ({size}) is equal to or smaller than 0.");
+ }
+
+ if (size > MaxSize)
+ {
+ return BadRequest($"The requested size ({size}) is larger than the max allowed value ({MaxSize}).");
+ }
+
+ byte[] buffer = ArrayPool<byte>.Shared.Rent(size);
+ try
+ {
+ new Random().NextBytes(buffer);
+ return File(buffer, MediaTypeNames.Application.Octet);
+ }
+ finally
+ {
+ 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();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs
new file mode 100644
index 000000000..a9af1a2c3
--- /dev/null
+++ b/Jellyfin.Api/Controllers/MoviesController.cs
@@ -0,0 +1,329 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Movies controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class MoviesController : BaseJellyfinApiController
+ {
+ private readonly IUserManager _userManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IDtoService _dtoService;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MoviesController"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public MoviesController(
+ IUserManager userManager,
+ ILibraryManager libraryManager,
+ IDtoService dtoService,
+ IServerConfigurationManager serverConfigurationManager)
+ {
+ _userManager = userManager;
+ _libraryManager = libraryManager;
+ _dtoService = dtoService;
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <summary>
+ /// Gets movie recommendations.
+ /// </summary>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. The fields to return.</param>
+ /// <param name="categoryLimit">The max number of categories to return.</param>
+ /// <param name="itemLimit">The max number of items to return per category.</param>
+ /// <response code="200">Movie recommendations returned.</response>
+ /// <returns>The list of movie recommendations.</returns>
+ [HttpGet("Recommendations")]
+ public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
+ [FromQuery] Guid? userId,
+ [FromQuery] string? parentId,
+ [FromQuery] string? fields,
+ [FromQuery] int categoryLimit = 5,
+ [FromQuery] int itemLimit = 8)
+ {
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request);
+
+ var categories = new List<RecommendationDto>();
+
+ var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+
+ var query = new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[]
+ {
+ nameof(Movie),
+ // typeof(Trailer).Name,
+ // typeof(LiveTvProgram).Name
+ },
+ // IsMovie = true
+ OrderBy = new[] { ItemSortBy.DatePlayed, ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
+ Limit = 7,
+ ParentId = parentIdGuid,
+ Recursive = true,
+ IsPlayed = true,
+ DtoOptions = dtoOptions
+ };
+
+ var recentlyPlayedMovies = _libraryManager.GetItemList(query);
+
+ var itemTypes = new List<string> { nameof(Movie) };
+ if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+ {
+ itemTypes.Add(nameof(Trailer));
+ itemTypes.Add(nameof(LiveTvProgram));
+ }
+
+ var likedMovies = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = itemTypes.ToArray(),
+ IsMovie = true,
+ OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
+ Limit = 10,
+ IsFavoriteOrLiked = true,
+ ExcludeItemIds = recentlyPlayedMovies.Select(i => i.Id).ToArray(),
+ EnableGroupByMetadataKey = true,
+ ParentId = parentIdGuid,
+ Recursive = true,
+ DtoOptions = dtoOptions
+ });
+
+ var mostRecentMovies = recentlyPlayedMovies.Take(6).ToList();
+ // Get recently played directors
+ var recentDirectors = GetDirectors(mostRecentMovies)
+ .ToList();
+
+ // Get recently played actors
+ var recentActors = GetActors(mostRecentMovies)
+ .ToList();
+
+ var similarToRecentlyPlayed = GetSimilarTo(user, recentlyPlayedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToRecentlyPlayed).GetEnumerator();
+ var similarToLiked = GetSimilarTo(user, likedMovies, itemLimit, dtoOptions, RecommendationType.SimilarToLikedItem).GetEnumerator();
+
+ var hasDirectorFromRecentlyPlayed = GetWithDirector(user, recentDirectors, itemLimit, dtoOptions, RecommendationType.HasDirectorFromRecentlyPlayed).GetEnumerator();
+ var hasActorFromRecentlyPlayed = GetWithActor(user, recentActors, itemLimit, dtoOptions, RecommendationType.HasActorFromRecentlyPlayed).GetEnumerator();
+
+ var categoryTypes = new List<IEnumerator<RecommendationDto>>
+ {
+ // Give this extra weight
+ similarToRecentlyPlayed,
+ similarToRecentlyPlayed,
+
+ // Give this extra weight
+ similarToLiked,
+ similarToLiked,
+ hasDirectorFromRecentlyPlayed,
+ hasActorFromRecentlyPlayed
+ };
+
+ while (categories.Count < categoryLimit)
+ {
+ var allEmpty = true;
+
+ foreach (var category in categoryTypes)
+ {
+ if (category.MoveNext())
+ {
+ categories.Add(category.Current);
+ allEmpty = false;
+
+ if (categories.Count >= categoryLimit)
+ {
+ break;
+ }
+ }
+ }
+
+ if (allEmpty)
+ {
+ break;
+ }
+ }
+
+ return Ok(categories.OrderBy(i => i.RecommendationType));
+ }
+
+ private IEnumerable<RecommendationDto> GetWithDirector(
+ User? user,
+ IEnumerable<string> names,
+ int itemLimit,
+ DtoOptions dtoOptions,
+ RecommendationType type)
+ {
+ var itemTypes = new List<string> { nameof(MediaBrowser.Controller.Entities.Movies.Movie) };
+ if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+ {
+ itemTypes.Add(nameof(Trailer));
+ itemTypes.Add(nameof(LiveTvProgram));
+ }
+
+ foreach (var name in names)
+ {
+ var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ Person = name,
+ // Account for duplicates by imdb id, since the database doesn't support this yet
+ Limit = itemLimit + 2,
+ PersonTypes = new[] { PersonType.Director },
+ IncludeItemTypes = itemTypes.ToArray(),
+ IsMovie = true,
+ EnableGroupByMetadataKey = true,
+ DtoOptions = dtoOptions
+ }).GroupBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
+ .Select(x => x.First())
+ .Take(itemLimit)
+ .ToList();
+
+ if (items.Count > 0)
+ {
+ var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
+
+ yield return new RecommendationDto
+ {
+ BaselineItemName = name,
+ CategoryId = name.GetMD5(),
+ RecommendationType = type,
+ Items = returnItems
+ };
+ }
+ }
+ }
+
+ private IEnumerable<RecommendationDto> GetWithActor(User? user, IEnumerable<string> names, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+ {
+ var itemTypes = new List<string> { nameof(Movie) };
+ if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+ {
+ itemTypes.Add(nameof(Trailer));
+ itemTypes.Add(nameof(LiveTvProgram));
+ }
+
+ foreach (var name in names)
+ {
+ var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ Person = name,
+ // Account for duplicates by imdb id, since the database doesn't support this yet
+ Limit = itemLimit + 2,
+ IncludeItemTypes = itemTypes.ToArray(),
+ IsMovie = true,
+ EnableGroupByMetadataKey = true,
+ DtoOptions = dtoOptions
+ }).GroupBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture))
+ .Select(x => x.First())
+ .Take(itemLimit)
+ .ToList();
+
+ if (items.Count > 0)
+ {
+ var returnItems = _dtoService.GetBaseItemDtos(items, dtoOptions, user);
+
+ yield return new RecommendationDto
+ {
+ BaselineItemName = name,
+ CategoryId = name.GetMD5(),
+ RecommendationType = type,
+ Items = returnItems
+ };
+ }
+ }
+ }
+
+ private IEnumerable<RecommendationDto> GetSimilarTo(User? user, IEnumerable<BaseItem> baselineItems, int itemLimit, DtoOptions dtoOptions, RecommendationType type)
+ {
+ var itemTypes = new List<string> { nameof(Movie) };
+ if (_serverConfigurationManager.Configuration.EnableExternalContentInSuggestions)
+ {
+ itemTypes.Add(nameof(Trailer));
+ itemTypes.Add(nameof(LiveTvProgram));
+ }
+
+ foreach (var item in baselineItems)
+ {
+ var similar = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ Limit = itemLimit,
+ IncludeItemTypes = itemTypes.ToArray(),
+ IsMovie = true,
+ SimilarTo = item,
+ EnableGroupByMetadataKey = true,
+ DtoOptions = dtoOptions
+ });
+
+ if (similar.Count > 0)
+ {
+ var returnItems = _dtoService.GetBaseItemDtos(similar, dtoOptions, user);
+
+ yield return new RecommendationDto
+ {
+ BaselineItemName = item.Name,
+ CategoryId = item.Id,
+ RecommendationType = type,
+ Items = returnItems
+ };
+ }
+ }
+ }
+
+ private IEnumerable<string> GetActors(IEnumerable<BaseItem> items)
+ {
+ var people = _libraryManager.GetPeople(new InternalPeopleQuery
+ {
+ ExcludePersonTypes = new[] { PersonType.Director },
+ MaxListOrder = 3
+ });
+
+ var itemIds = items.Select(i => i.Id).ToList();
+
+ return people
+ .Where(i => itemIds.Contains(i.ItemId))
+ .Select(i => i.Name)
+ .DistinctNames();
+ }
+
+ private IEnumerable<string> GetDirectors(IEnumerable<BaseItem> items)
+ {
+ var people = _libraryManager.GetPeople(new InternalPeopleQuery
+ {
+ PersonTypes = new[] { PersonType.Director }
+ });
+
+ var itemIds = items.Select(i => i.Id).ToList();
+
+ return people
+ .Where(i => itemIds.Contains(i.ItemId))
+ .Select(i => i.Name)
+ .DistinctNames();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs
new file mode 100644
index 000000000..0d319137a
--- /dev/null
+++ b/Jellyfin.Api/Controllers/MusicGenresController.cs
@@ -0,0 +1,313 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// The music genres controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class MusicGenresController : BaseJellyfinApiController
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IDtoService _dtoService;
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MusicGenresController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="userManager">Instance of <see cref="IUserManager"/> interface.</param>
+ /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
+ public MusicGenresController(
+ ILibraryManager libraryManager,
+ IUserManager userManager,
+ IDtoService dtoService)
+ {
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _dtoService = dtoService;
+ }
+
+ /// <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>
+ /// <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="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>
+ /// <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">Music genres returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the queryresult of music genres.</returns>
+ [HttpGet]
+ public ActionResult<QueryResult<BaseItemDto>> GetMusicGenres(
+ [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] 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] Guid? userId,
+ [FromQuery] string? nameStartsWithOrGreater,
+ [FromQuery] string? nameStartsWith,
+ [FromQuery] string? nameLessThan,
+ [FromQuery] bool? enableImages = true,
+ [FromQuery] bool enableTotalRecordCount = true)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .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)
+ {
+ 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 = _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
+ };
+ }
+
+ /// <summary>
+ /// Gets a music genre, by name.
+ /// </summary>
+ /// <param name="genreName">The genre name.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <returns>An <see cref="OkResult"/> containing a <see cref="BaseItemDto"/> with the music genre.</returns>
+ [HttpGet("{genreName}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<BaseItemDto> GetMusicGenre([FromRoute] string genreName, [FromQuery] Guid? userId)
+ {
+ var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+ MusicGenre item;
+
+ if (genreName.IndexOf(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ item = GetItemFromSlugName<MusicGenre>(_libraryManager, genreName, dtoOptions);
+ }
+ else
+ {
+ item = _libraryManager.GetMusicGenre(genreName);
+ }
+
+ if (userId.HasValue && !userId.Equals(Guid.Empty))
+ {
+ var user = _userManager.GetUserById(userId.Value);
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+ }
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions);
+ }
+
+ private T GetItemFromSlugName<T>(ILibraryManager libraryManager, string name, DtoOptions dtoOptions)
+ where T : BaseItem, new()
+ {
+ var result = libraryManager.GetItemList(new InternalItemsQuery
+ {
+ Name = name.Replace(BaseItem.SlugChar, '&'),
+ IncludeItemTypes = new[] { typeof(T).Name },
+ DtoOptions = dtoOptions
+ }).OfType<T>().FirstOrDefault();
+
+ result ??= libraryManager.GetItemList(new InternalItemsQuery
+ {
+ Name = name.Replace(BaseItem.SlugChar, '/'),
+ IncludeItemTypes = new[] { typeof(T).Name },
+ DtoOptions = dtoOptions
+ }).OfType<T>().FirstOrDefault();
+
+ result ??= libraryManager.GetItemList(new InternalItemsQuery
+ {
+ Name = name.Replace(BaseItem.SlugChar, '?'),
+ IncludeItemTypes = new[] { typeof(T).Name },
+ DtoOptions = dtoOptions
+ }).OfType<T>().FirstOrDefault();
+
+ return result;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/NotificationsController.cs b/Jellyfin.Api/Controllers/NotificationsController.cs
index cfa7545c9..1bb39b5f7 100644
--- a/Jellyfin.Api/Controllers/NotificationsController.cs
+++ b/Jellyfin.Api/Controllers/NotificationsController.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using Jellyfin.Api.Models.NotificationDtos;
@@ -93,8 +92,8 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Admin")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult CreateAdminNotification(
- [FromQuery] string name,
- [FromQuery] string description,
+ [FromQuery] string? name,
+ [FromQuery] string? description,
[FromQuery] string? url,
[FromQuery] NotificationLevel? level)
{
diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs
index 486575d23..a6c552790 100644
--- a/Jellyfin.Api/Controllers/PackageController.cs
+++ b/Jellyfin.Api/Controllers/PackageController.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -20,14 +21,17 @@ namespace Jellyfin.Api.Controllers
public class PackageController : BaseJellyfinApiController
{
private readonly IInstallationManager _installationManager;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="PackageController"/> class.
/// </summary>
- /// <param name="installationManager">Instance of <see cref="IInstallationManager"/>Installation Manager.</param>
- public PackageController(IInstallationManager installationManager)
+ /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager)
{
_installationManager = installationManager;
+ _serverConfigurationManager = serverConfigurationManager;
}
/// <summary>
@@ -40,7 +44,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("/{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);
@@ -80,9 +84,9 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Authorize(Policy = Policies.RequiresElevation)]
public async Task<ActionResult> InstallPackage(
- [FromRoute] [Required] string name,
- [FromQuery] string assemblyGuid,
- [FromQuery] string version)
+ [FromRoute] [Required] string? name,
+ [FromQuery] string? assemblyGuid,
+ [FromQuery] string? version)
{
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
var package = _installationManager.GetCompatibleVersions(
@@ -110,11 +114,39 @@ namespace Jellyfin.Api.Controllers
[HttpDelete("/Installing/{packageId}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public IActionResult CancelPackageInstallation(
+ public ActionResult CancelPackageInstallation(
[FromRoute] [Required] Guid packageId)
{
_installationManager.CancelInstallation(packageId);
return NoContent();
}
+
+ /// <summary>
+ /// Gets all package repositories.
+ /// </summary>
+ /// <response code="200">Package repositories returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the list of package repositories.</returns>
+ [HttpGet("/Repositories")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<RepositoryInfo>> GetRepositories()
+ {
+ return _serverConfigurationManager.Configuration.PluginRepositories;
+ }
+
+ /// <summary>
+ /// Sets the enabled and existing package repositories.
+ /// </summary>
+ /// <param name="repositoryInfos">The list of package repositories.</param>
+ /// <response code="204">Package repositories saved.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpOptions("/Repositories")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SetRepositories([FromBody] List<RepositoryInfo> repositoryInfos)
+ {
+ _serverConfigurationManager.Configuration.PluginRepositories = repositoryInfos;
+ return NoContent();
+ }
}
}
diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs
new file mode 100644
index 000000000..23cc23ce7
--- /dev/null
+++ b/Jellyfin.Api/Controllers/PersonsController.cs
@@ -0,0 +1,282 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Persons controller.
+ /// </summary>
+ public class PersonsController : BaseJellyfinApiController
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IDtoService _dtoService;
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PersonsController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ public PersonsController(
+ ILibraryManager libraryManager,
+ IDtoService dtoService,
+ IUserManager userManager)
+ {
+ _libraryManager = libraryManager;
+ _dtoService = dtoService;
+ _userManager = userManager;
+ }
+
+ /// <summary>
+ /// Gets all persons 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>
+ /// <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="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>
+ /// <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] 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] Guid? userId,
+ [FromQuery] string? nameStartsWithOrGreater,
+ [FromQuery] string? nameStartsWith,
+ [FromQuery] string? nameLessThan,
+ [FromQuery] bool? enableImages = true,
+ [FromQuery] bool enableTotalRecordCount = true)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .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)
+ {
+ 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;
+ });
+
+ return new QueryResult<BaseItemDto>
+ {
+ Items = dtos.ToArray(),
+ TotalRecordCount = result.TotalRecordCount
+ };
+ }
+
+ /// <summary>
+ /// Get person by name.
+ /// </summary>
+ /// <param name="name">Person name.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <response code="200">Person returned.</response>
+ /// <response code="404">Person not found.</response>
+ /// <returns>An <see cref="OkResult"/> containing the person on success,
+ /// or a <see cref="NotFoundResult"/> if person not found.</returns>
+ [HttpGet("{name}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<BaseItemDto> GetPerson([FromRoute] string name, [FromQuery] Guid? userId)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddClientFields(Request);
+
+ var item = _libraryManager.GetPerson(name);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ if (userId.HasValue && !userId.Equals(Guid.Empty))
+ {
+ var user = _userManager.GetUserById(userId.Value);
+ return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+ }
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs
index 2dc0d2dc7..cf4660494 100644
--- a/Jellyfin.Api/Controllers/PlaylistsController.cs
+++ b/Jellyfin.Api/Controllers/PlaylistsController.cs
@@ -84,11 +84,11 @@ namespace Jellyfin.Api.Controllers
[HttpPost("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult AddToPlaylist(
- [FromRoute] string playlistId,
- [FromQuery] string ids,
- [FromQuery] Guid userId)
+ [FromRoute] string? playlistId,
+ [FromQuery] string? ids,
+ [FromQuery] Guid? userId)
{
- _playlistManager.AddToPlaylist(playlistId, RequestHelpers.GetGuids(ids), userId);
+ _playlistManager.AddToPlaylist(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty);
return NoContent();
}
@@ -103,8 +103,8 @@ namespace Jellyfin.Api.Controllers
[HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult MoveItem(
- [FromRoute] string playlistId,
- [FromRoute] string itemId,
+ [FromRoute] string? playlistId,
+ [FromRoute] string? itemId,
[FromRoute] int newIndex)
{
_playlistManager.MoveItem(playlistId, itemId, newIndex);
@@ -120,7 +120,7 @@ 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 ActionResult RemoveFromPlaylist([FromRoute] string? playlistId, [FromQuery] string? entryIds)
{
_playlistManager.RemoveFromPlaylist(playlistId, RequestHelpers.Split(entryIds, ',', true));
return NoContent();
@@ -147,11 +147,11 @@ namespace Jellyfin.Api.Controllers
[FromRoute] Guid userId,
[FromRoute] int? startIndex,
[FromRoute] int? limit,
- [FromRoute] string fields,
+ [FromRoute] string? fields,
[FromRoute] bool? enableImages,
[FromRoute] bool? enableUserData,
[FromRoute] int? imageTypeLimit,
- [FromRoute] string enableImageTypes)
+ [FromRoute] string? enableImageTypes)
{
var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
if (playlist == null)
diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs
new file mode 100644
index 000000000..9fcf04199
--- /dev/null
+++ b/Jellyfin.Api/Controllers/PlaystateController.cs
@@ -0,0 +1,372 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Playstate controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class PlaystateController : BaseJellyfinApiController
+ {
+ private readonly IUserManager _userManager;
+ private readonly IUserDataManager _userDataRepository;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ISessionManager _sessionManager;
+ private readonly IAuthorizationContext _authContext;
+ private readonly ILogger<PlaystateController> _logger;
+ private readonly TranscodingJobHelper _transcodingJobHelper;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlaystateController"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+ /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ public PlaystateController(
+ IUserManager userManager,
+ IUserDataManager userDataRepository,
+ ILibraryManager libraryManager,
+ ISessionManager sessionManager,
+ IAuthorizationContext authContext,
+ ILoggerFactory loggerFactory,
+ IMediaSourceManager mediaSourceManager,
+ IFileSystem fileSystem)
+ {
+ _userManager = userManager;
+ _userDataRepository = userDataRepository;
+ _libraryManager = libraryManager;
+ _sessionManager = sessionManager;
+ _authContext = authContext;
+ _logger = loggerFactory.CreateLogger<PlaystateController>();
+
+ _transcodingJobHelper = new TranscodingJobHelper(
+ loggerFactory.CreateLogger<TranscodingJobHelper>(),
+ mediaSourceManager,
+ fileSystem);
+ }
+
+ /// <summary>
+ /// Marks an item as played for user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="datePlayed">Optional. The date the item was played.</param>
+ /// <response code="200">Item marked as played.</response>
+ /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+ [HttpPost("/Users/{userId}/PlayedItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<UserItemDataDto> MarkPlayedItem(
+ [FromRoute] Guid userId,
+ [FromRoute] Guid itemId,
+ [FromQuery] DateTime? datePlayed)
+ {
+ var user = _userManager.GetUserById(userId);
+ var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+ var dto = UpdatePlayedStatus(user, itemId, true, datePlayed);
+ foreach (var additionalUserInfo in session.AdditionalUsers)
+ {
+ var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
+ UpdatePlayedStatus(additionalUser, itemId, true, datePlayed);
+ }
+
+ return dto;
+ }
+
+ /// <summary>
+ /// Marks an item as unplayed for user.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Item marked as unplayed.</response>
+ /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+ [HttpDelete("/Users/{userId}/PlayedItem/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<UserItemDataDto> MarkUnplayedItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
+ {
+ var user = _userManager.GetUserById(userId);
+ var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+ var dto = UpdatePlayedStatus(user, itemId, false, null);
+ foreach (var additionalUserInfo in session.AdditionalUsers)
+ {
+ var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
+ UpdatePlayedStatus(additionalUser, itemId, false, null);
+ }
+
+ return dto;
+ }
+
+ /// <summary>
+ /// Reports playback has started within a session.
+ /// </summary>
+ /// <param name="playbackStartInfo">The playback start info.</param>
+ /// <response code="204">Playback start recorded.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("/Sessions/Playing")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo)
+ {
+ playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
+ playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+ await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Reports playback progress within a session.
+ /// </summary>
+ /// <param name="playbackProgressInfo">The playback progress info.</param>
+ /// <response code="204">Playback progress recorded.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("/Sessions/Playing/Progress")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo)
+ {
+ playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
+ playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+ await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Pings a playback session.
+ /// </summary>
+ /// <param name="playSessionId">Playback session id.</param>
+ /// <response code="204">Playback session pinged.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("/Sessions/Playing/Ping")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult PingPlaybackSession([FromQuery] string playSessionId)
+ {
+ _transcodingJobHelper.PingTranscodingJob(playSessionId, null);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Reports playback has stopped within a session.
+ /// </summary>
+ /// <param name="playbackStopInfo">The playback stop info.</param>
+ /// <response code="204">Playback stop recorded.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("/Sessions/Playing/Stopped")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo)
+ {
+ _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
+ if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
+ {
+ await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
+ }
+
+ playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+ await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Reports that a user has begun playing an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="mediaSourceId">The id of the MediaSource.</param>
+ /// <param name="audioStreamIndex">The audio stream index.</param>
+ /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+ /// <param name="playMethod">The play method.</param>
+ /// <param name="liveStreamId">The live stream id.</param>
+ /// <param name="playSessionId">The play session id.</param>
+ /// <param name="canSeek">Indicates if the client can seek.</param>
+ /// <response code="204">Play start recorded.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("/Users/{userId}/PlayingItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+ public async Task<ActionResult> OnPlaybackStart(
+ [FromRoute] Guid userId,
+ [FromRoute] Guid itemId,
+ [FromQuery] string? mediaSourceId,
+ [FromQuery] int? audioStreamIndex,
+ [FromQuery] int? subtitleStreamIndex,
+ [FromQuery] PlayMethod playMethod,
+ [FromQuery] string? liveStreamId,
+ [FromQuery] string playSessionId,
+ [FromQuery] bool canSeek = false)
+ {
+ var playbackStartInfo = new PlaybackStartInfo
+ {
+ CanSeek = canSeek,
+ ItemId = itemId,
+ MediaSourceId = mediaSourceId,
+ AudioStreamIndex = audioStreamIndex,
+ SubtitleStreamIndex = subtitleStreamIndex,
+ PlayMethod = playMethod,
+ PlaySessionId = playSessionId,
+ LiveStreamId = liveStreamId
+ };
+
+ playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
+ playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+ await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Reports a user's playback progress.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="mediaSourceId">The id of the MediaSource.</param>
+ /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
+ /// <param name="audioStreamIndex">The audio stream index.</param>
+ /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+ /// <param name="volumeLevel">Scale of 0-100.</param>
+ /// <param name="playMethod">The play method.</param>
+ /// <param name="liveStreamId">The live stream id.</param>
+ /// <param name="playSessionId">The play session id.</param>
+ /// <param name="repeatMode">The repeat mode.</param>
+ /// <param name="isPaused">Indicates if the player is paused.</param>
+ /// <param name="isMuted">Indicates if the player is muted.</param>
+ /// <response code="204">Play progress recorded.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("/Users/{userId}/PlayingItems/{itemId}/Progress")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+ public async Task<ActionResult> OnPlaybackProgress(
+ [FromRoute] Guid userId,
+ [FromRoute] Guid itemId,
+ [FromQuery] string? mediaSourceId,
+ [FromQuery] long? positionTicks,
+ [FromQuery] int? audioStreamIndex,
+ [FromQuery] int? subtitleStreamIndex,
+ [FromQuery] int? volumeLevel,
+ [FromQuery] PlayMethod playMethod,
+ [FromQuery] string? liveStreamId,
+ [FromQuery] string playSessionId,
+ [FromQuery] RepeatMode repeatMode,
+ [FromQuery] bool isPaused = false,
+ [FromQuery] bool isMuted = false)
+ {
+ var playbackProgressInfo = new PlaybackProgressInfo
+ {
+ ItemId = itemId,
+ PositionTicks = positionTicks,
+ IsMuted = isMuted,
+ IsPaused = isPaused,
+ MediaSourceId = mediaSourceId,
+ AudioStreamIndex = audioStreamIndex,
+ SubtitleStreamIndex = subtitleStreamIndex,
+ VolumeLevel = volumeLevel,
+ PlayMethod = playMethod,
+ PlaySessionId = playSessionId,
+ LiveStreamId = liveStreamId,
+ RepeatMode = repeatMode
+ };
+
+ playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
+ playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+ await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Reports that a user has stopped playing an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="mediaSourceId">The id of the MediaSource.</param>
+ /// <param name="nextMediaType">The next media type that will play.</param>
+ /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
+ /// <param name="liveStreamId">The live stream id.</param>
+ /// <param name="playSessionId">The play session id.</param>
+ /// <response code="204">Playback stop recorded.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("/Users/{userId}/PlayingItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+ public async Task<ActionResult> OnPlaybackStopped(
+ [FromRoute] Guid userId,
+ [FromRoute] Guid itemId,
+ [FromQuery] string? mediaSourceId,
+ [FromQuery] string? nextMediaType,
+ [FromQuery] long? positionTicks,
+ [FromQuery] string? liveStreamId,
+ [FromQuery] string? playSessionId)
+ {
+ var playbackStopInfo = new PlaybackStopInfo
+ {
+ ItemId = itemId,
+ PositionTicks = positionTicks,
+ MediaSourceId = mediaSourceId,
+ PlaySessionId = playSessionId,
+ LiveStreamId = liveStreamId,
+ NextMediaType = nextMediaType
+ };
+
+ _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
+ if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
+ {
+ await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
+ }
+
+ playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+ await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Updates the played status.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="wasPlayed">if set to <c>true</c> [was played].</param>
+ /// <param name="datePlayed">The date played.</param>
+ /// <returns>Task.</returns>
+ private UserItemDataDto UpdatePlayedStatus(User user, Guid itemId, bool wasPlayed, DateTime? datePlayed)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+
+ if (wasPlayed)
+ {
+ item.MarkPlayed(user, datePlayed, true);
+ }
+ else
+ {
+ item.MarkUnplayed(user);
+ }
+
+ return _userDataRepository.GetUserDataDto(item, user);
+ }
+
+ private PlayMethod ValidatePlayMethod(PlayMethod method, string playSessionId)
+ {
+ if (method == PlayMethod.Transcode)
+ {
+ var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId);
+ if (job == null)
+ {
+ return PlayMethod.DirectPlay;
+ }
+ }
+
+ return method;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs
index fd48983ea..48bb867ac 100644
--- a/Jellyfin.Api/Controllers/PluginsController.cs
+++ b/Jellyfin.Api/Controllers/PluginsController.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
@@ -166,7 +165,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] string? name)
{
return new MBRegistrationRecord
{
@@ -188,7 +187,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] 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.
diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs
index a0d14be7a..1b26163cf 100644
--- a/Jellyfin.Api/Controllers/RemoteImageController.cs
+++ b/Jellyfin.Api/Controllers/RemoteImageController.cs
@@ -74,7 +74,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string providerName,
- [FromQuery] bool includeAllLanguages)
+ [FromQuery] bool includeAllLanguages = false)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -208,7 +208,7 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult> DownloadRemoteImage(
[FromRoute] Guid itemId,
[FromQuery, BindRequired] ImageType type,
- [FromQuery] string imageUrl)
+ [FromQuery] string? imageUrl)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
diff --git a/Jellyfin.Api/Controllers/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs
new file mode 100644
index 000000000..3df325e3b
--- /dev/null
+++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Model.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Scheduled Tasks Controller.
+ /// </summary>
+ [Authorize(Policy = Policies.RequiresElevation)]
+ public class ScheduledTasksController : BaseJellyfinApiController
+ {
+ private readonly ITaskManager _taskManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ScheduledTasksController"/> class.
+ /// </summary>
+ /// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param>
+ public ScheduledTasksController(ITaskManager taskManager)
+ {
+ _taskManager = taskManager;
+ }
+
+ /// <summary>
+ /// Get tasks.
+ /// </summary>
+ /// <param name="isHidden">Optional filter tasks that are hidden, or not.</param>
+ /// <param name="isEnabled">Optional filter tasks that are enabled, or not.</param>
+ /// <response code="200">Scheduled tasks retrieved.</response>
+ /// <returns>The list of scheduled tasks.</returns>
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public IEnumerable<IScheduledTaskWorker> GetTasks(
+ [FromQuery] bool? isHidden,
+ [FromQuery] bool? isEnabled)
+ {
+ IEnumerable<IScheduledTaskWorker> tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name);
+
+ foreach (var task in tasks)
+ {
+ if (task.ScheduledTask is IConfigurableScheduledTask scheduledTask)
+ {
+ if (isHidden.HasValue && isHidden.Value != scheduledTask.IsHidden)
+ {
+ continue;
+ }
+
+ if (isEnabled.HasValue && isEnabled.Value != scheduledTask.IsEnabled)
+ {
+ continue;
+ }
+ }
+
+ yield return task;
+ }
+ }
+
+ /// <summary>
+ /// Get task by id.
+ /// </summary>
+ /// <param name="taskId">Task Id.</param>
+ /// <response code="200">Task retrieved.</response>
+ /// <response code="404">Task not found.</response>
+ /// <returns>An <see cref="OkResult"/> containing the task on success, or a <see cref="NotFoundResult"/> if the task could not be found.</returns>
+ [HttpGet("{taskId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<TaskInfo> GetTask([FromRoute] string? taskId)
+ {
+ var task = _taskManager.ScheduledTasks.FirstOrDefault(i =>
+ string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase));
+
+ if (task == null)
+ {
+ return NotFound();
+ }
+
+ return ScheduledTaskHelpers.GetTaskInfo(task);
+ }
+
+ /// <summary>
+ /// Start specified task.
+ /// </summary>
+ /// <param name="taskId">Task Id.</param>
+ /// <response code="204">Task started.</response>
+ /// <response code="404">Task not found.</response>
+ /// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+ [HttpPost("Running/{taskId}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult StartTask([FromRoute] string? taskId)
+ {
+ var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+ o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+
+ if (task == null)
+ {
+ return NotFound();
+ }
+
+ _taskManager.Execute(task, new TaskOptions());
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Stop specified task.
+ /// </summary>
+ /// <param name="taskId">Task Id.</param>
+ /// <response code="204">Task stopped.</response>
+ /// <response code="404">Task not found.</response>
+ /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+ [HttpDelete("Running/{taskId}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult StopTask([FromRoute] string? taskId)
+ {
+ var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+ o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+
+ if (task == null)
+ {
+ return NotFound();
+ }
+
+ _taskManager.Cancel(task);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Update specified task triggers.
+ /// </summary>
+ /// <param name="taskId">Task Id.</param>
+ /// <param name="triggerInfos">Triggers.</param>
+ /// <response code="204">Task triggers updated.</response>
+ /// <response code="404">Task not found.</response>
+ /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the file could not be found.</returns>
+ [HttpPost("{taskId}/Triggers")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult UpdateTask(
+ [FromRoute] string? taskId,
+ [FromBody, BindRequired] TaskTriggerInfo[] triggerInfos)
+ {
+ var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+ o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+ if (task == null)
+ {
+ return NotFound();
+ }
+
+ task.Triggers = triggerInfos;
+ return NoContent();
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs
index d971889db..2cbd32d2f 100644
--- a/Jellyfin.Api/Controllers/SearchController.cs
+++ b/Jellyfin.Api/Controllers/SearchController.cs
@@ -80,12 +80,12 @@ namespace Jellyfin.Api.Controllers
public ActionResult<SearchHintResult> Get(
[FromQuery] int? startIndex,
[FromQuery] int? limit,
- [FromQuery] Guid userId,
- [FromQuery, Required] string searchTerm,
- [FromQuery] string includeItemTypes,
- [FromQuery] string excludeItemTypes,
- [FromQuery] string mediaTypes,
- [FromQuery] string parentId,
+ [FromQuery] Guid? userId,
+ [FromQuery, Required] string? searchTerm,
+ [FromQuery] string? includeItemTypes,
+ [FromQuery] string? excludeItemTypes,
+ [FromQuery] string? mediaTypes,
+ [FromQuery] string? parentId,
[FromQuery] bool? isMovie,
[FromQuery] bool? isSeries,
[FromQuery] bool? isNews,
@@ -107,7 +107,7 @@ namespace Jellyfin.Api.Controllers
IncludePeople = includePeople,
IncludeStudios = includeStudios,
StartIndex = startIndex,
- UserId = userId,
+ UserId = userId ?? Guid.Empty,
IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs
index 39da4178d..0c98a8e71 100644
--- a/Jellyfin.Api/Controllers/SessionController.cs
+++ b/Jellyfin.Api/Controllers/SessionController.cs
@@ -61,8 +61,8 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<SessionInfo>> GetSessions(
- [FromQuery] Guid controllableByUserId,
- [FromQuery] string deviceId,
+ [FromQuery] Guid? controllableByUserId,
+ [FromQuery] string? deviceId,
[FromQuery] int? activeWithinSeconds)
{
var result = _sessionManager.Sessions;
@@ -72,15 +72,15 @@ namespace Jellyfin.Api.Controllers
result = result.Where(i => string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase));
}
- if (!controllableByUserId.Equals(Guid.Empty))
+ if (controllableByUserId.HasValue && !controllableByUserId.Equals(Guid.Empty))
{
result = result.Where(i => i.SupportsRemoteControl);
- var user = _userManager.GetUserById(controllableByUserId);
+ var user = _userManager.GetUserById(controllableByUserId.Value);
if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
{
- result = result.Where(i => i.UserId.Equals(Guid.Empty) || i.ContainsUser(controllableByUserId));
+ result = result.Where(i => i.UserId.Equals(Guid.Empty) || i.ContainsUser(controllableByUserId.Value));
}
if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl))
@@ -123,10 +123,10 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/{sessionId}/Viewing")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DisplayContent(
- [FromRoute] string sessionId,
- [FromQuery] string itemType,
- [FromQuery] string itemId,
- [FromQuery] string itemName)
+ [FromRoute] string? sessionId,
+ [FromQuery] string? itemType,
+ [FromQuery] string? itemId,
+ [FromQuery] string? itemName)
{
var command = new BrowseRequest
{
@@ -157,7 +157,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/{sessionId}/Playing")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult Play(
- [FromRoute] string sessionId,
+ [FromRoute] string? sessionId,
[FromQuery] Guid[] itemIds,
[FromQuery] long? startPositionTicks,
[FromQuery] PlayCommand playCommand,
@@ -191,7 +191,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/{sessionId}/Playing/{command}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SendPlaystateCommand(
- [FromRoute] string sessionId,
+ [FromRoute] string? sessionId,
[FromBody] PlaystateRequest playstateRequest)
{
_sessionManager.SendPlaystateCommand(
@@ -213,8 +213,8 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/{sessionId}/System/{command}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SendSystemCommand(
- [FromRoute] string sessionId,
- [FromRoute] string command)
+ [FromRoute] string? sessionId,
+ [FromRoute] string? command)
{
var name = command;
if (Enum.TryParse(name, true, out GeneralCommandType commandType))
@@ -244,8 +244,8 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/{sessionId}/Command/{Command}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SendGeneralCommand(
- [FromRoute] string sessionId,
- [FromRoute] string command)
+ [FromRoute] string? sessionId,
+ [FromRoute] string? command)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
@@ -270,7 +270,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/{sessionId}/Command")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SendFullGeneralCommand(
- [FromRoute] string sessionId,
+ [FromRoute] string? sessionId,
[FromBody, Required] GeneralCommand command)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
@@ -303,9 +303,9 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/{sessionId}/Message")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SendMessageCommand(
- [FromRoute] string sessionId,
- [FromQuery] string text,
- [FromQuery] string header,
+ [FromRoute] string? sessionId,
+ [FromQuery] string? text,
+ [FromQuery] string? header,
[FromQuery] long? timeoutMs)
{
var command = new MessageCommand
@@ -330,7 +330,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/{sessionId}/User/{userId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult AddUserToSession(
- [FromRoute] string sessionId,
+ [FromRoute] string? sessionId,
[FromRoute] Guid userId)
{
_sessionManager.AddAdditionalUser(sessionId, userId);
@@ -347,7 +347,7 @@ namespace Jellyfin.Api.Controllers
[HttpDelete("/Sessions/{sessionId}/User/{userId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult RemoveUserFromSession(
- [FromRoute] string sessionId,
+ [FromRoute] string? sessionId,
[FromRoute] Guid userId)
{
_sessionManager.RemoveAdditionalUser(sessionId, userId);
@@ -368,11 +368,11 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/Capabilities")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostCapabilities(
- [FromQuery] string id,
- [FromQuery] string playableMediaTypes,
- [FromQuery] string supportedCommands,
- [FromQuery] bool supportsMediaControl,
- [FromQuery] bool supportsSync,
+ [FromQuery] string? id,
+ [FromQuery] string? playableMediaTypes,
+ [FromQuery] string? supportedCommands,
+ [FromQuery] bool supportsMediaControl = false,
+ [FromQuery] bool supportsSync = false,
[FromQuery] bool supportsPersistentIdentifier = true)
{
if (string.IsNullOrWhiteSpace(id))
@@ -401,7 +401,7 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/Capabilities/Full")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostFullCapabilities(
- [FromQuery] string id,
+ [FromQuery] string? id,
[FromBody, Required] ClientCapabilities capabilities)
{
if (string.IsNullOrWhiteSpace(id))
@@ -424,8 +424,8 @@ namespace Jellyfin.Api.Controllers
[HttpPost("/Sessions/Viewing")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult ReportViewing(
- [FromQuery] string sessionId,
- [FromQuery] string itemId)
+ [FromQuery] string? sessionId,
+ [FromQuery] string? itemId)
{
string session = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs
index d96b0f993..252b5cdd1 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -40,7 +40,6 @@ namespace Jellyfin.Api.Controllers
public ActionResult CompleteWizard()
{
_config.Configuration.IsStartupWizardCompleted = true;
- _config.SetOptimalValues();
_config.SaveConfiguration();
return NoContent();
}
@@ -75,9 +74,9 @@ namespace Jellyfin.Api.Controllers
[HttpPost("Configuration")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateInitialConfiguration(
- [FromForm] string uiCulture,
- [FromForm] string metadataCountryCode,
- [FromForm] string preferredMetadataLanguage)
+ [FromForm] string? uiCulture,
+ [FromForm] string? metadataCountryCode,
+ [FromForm] string? preferredMetadataLanguage)
{
_config.Configuration.UICulture = uiCulture;
_config.Configuration.MetadataCountryCode = metadataCountryCode;
diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs
new file mode 100644
index 000000000..6f2787d93
--- /dev/null
+++ b/Jellyfin.Api/Controllers/StudiosController.cs
@@ -0,0 +1,277 @@
+using System;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+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;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Studios controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class StudiosController : BaseJellyfinApiController
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly IDtoService _dtoService;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StudiosController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ public StudiosController(
+ ILibraryManager libraryManager,
+ IUserManager userManager,
+ IDtoService dtoService)
+ {
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _dtoService = dtoService;
+ }
+
+ /// <summary>
+ /// Gets all studios from a given item, folder, or the entire library.
+ /// </summary>
+ /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="searchTerm">Optional. Search term.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. 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>
+ /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+ /// <param name="enableImages">Optional, include image information in output.</param>
+ /// <param name="enableTotalRecordCount">Total record count.</param>
+ /// <response code="200">Studios returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the studios.</returns>
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetStudios(
+ [FromQuery] 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] 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] Guid? userId,
+ [FromQuery] string? nameStartsWithOrGreater,
+ [FromQuery] string? nameStartsWith,
+ [FromQuery] string? nameLessThan,
+ [FromQuery] bool? enableImages = true,
+ [FromQuery] bool enableTotalRecordCount = true)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .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 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
+ };
+
+ 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, 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
+ };
+ }
+
+ /// <summary>
+ /// Gets a studio by name.
+ /// </summary>
+ /// <param name="name">Studio name.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <response code="200">Studio returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the studio.</returns>
+ [HttpGet("{name}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<BaseItemDto> GetStudio([FromRoute] string name, [FromQuery] Guid? userId)
+ {
+ var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+ var item = _libraryManager.GetStudio(name);
+ if (userId.HasValue && !userId.Equals(Guid.Empty))
+ {
+ var user = _userManager.GetUserById(userId.Value);
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+ }
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index 95cc39524..1c38b8de5 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -112,7 +112,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
[FromRoute] Guid itemId,
- [FromRoute] string language,
+ [FromRoute] string? language,
[FromQuery] bool? isPerfectMatch)
{
var video = (Video)_libraryManager.GetItemById(itemId);
@@ -132,7 +132,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> DownloadRemoteSubtitles(
[FromRoute] Guid itemId,
- [FromRoute] string subtitleId)
+ [FromRoute] string? subtitleId)
{
var video = (Video)_libraryManager.GetItemById(itemId);
@@ -161,7 +161,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Application.Octet)]
- public async Task<ActionResult> GetRemoteSubtitles([FromRoute] string id)
+ public async Task<ActionResult> GetRemoteSubtitles([FromRoute] string? id)
{
var result = await _subtitleManager.GetRemoteSubtitles(id, CancellationToken.None).ConfigureAwait(false);
@@ -186,12 +186,12 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
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,
- [FromQuery] bool addVttTimeMap,
+ [FromQuery] bool copyTimestamps = false,
+ [FromQuery] bool addVttTimeMap = false,
[FromRoute] long startPositionTicks = 0)
{
if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
@@ -254,7 +254,7 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult> GetSubtitlePlaylist(
[FromRoute] Guid itemId,
[FromRoute] int index,
- [FromRoute] string mediaSourceId,
+ [FromRoute] string? mediaSourceId,
[FromQuery, Required] int segmentLength)
{
var item = (Video)_libraryManager.GetItemById(itemId);
@@ -324,7 +324,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="Task{Stream}"/> with the new subtitle file.</returns>
private Task<Stream> EncodeSubtitles(
Guid id,
- string mediaSourceId,
+ string? mediaSourceId,
int index,
string format,
long startPositionTicks,
diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs
index e1a99a138..bf3c1e2b1 100644
--- a/Jellyfin.Api/Controllers/SuggestionsController.cs
+++ b/Jellyfin.Api/Controllers/SuggestionsController.cs
@@ -44,9 +44,9 @@ namespace Jellyfin.Api.Controllers
/// <param name="userId">The user id.</param>
/// <param name="mediaType">The media types.</param>
/// <param name="type">The type.</param>
- /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
/// <param name="startIndex">Optional. The start index.</param>
/// <param name="limit">Optional. The limit.</param>
+ /// <param name="enableTotalRecordCount">Whether to enable the total record count.</param>
/// <response code="200">Suggestions returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the suggestions.</returns>
[HttpGet("/Users/{userId}/Suggestions")]
@@ -55,9 +55,9 @@ namespace Jellyfin.Api.Controllers
[FromRoute] Guid userId,
[FromQuery] string? mediaType,
[FromQuery] string? type,
- [FromQuery] bool enableTotalRecordCount,
[FromQuery] int? startIndex,
- [FromQuery] int? limit)
+ [FromQuery] int? limit,
+ [FromQuery] bool enableTotalRecordCount = false)
{
var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null;
diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs
index e33821b24..bc606f7aa 100644
--- a/Jellyfin.Api/Controllers/SystemController.cs
+++ b/Jellyfin.Api/Controllers/SystemController.cs
@@ -193,7 +193,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Logs/Log")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult GetLogFile([FromQuery, Required] string name)
+ public ActionResult GetLogFile([FromQuery, Required] string? name)
{
var file = _fileSystem.GetFiles(_appPaths.LogDirectoryPath)
.First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs
new file mode 100644
index 000000000..645495551
--- /dev/null
+++ b/Jellyfin.Api/Controllers/TrailersController.cs
@@ -0,0 +1,307 @@
+using System;
+using Jellyfin.Api.Constants;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// The trailers controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class TrailersController : BaseJellyfinApiController
+ {
+ private readonly IUserManager _userManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger<ItemsController> _logger;
+ private readonly IDtoService _dtoService;
+ private readonly ILocalizationManager _localizationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="TrailersController"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ public TrailersController(
+ ILoggerFactory loggerFactory,
+ IUserManager userManager,
+ ILibraryManager libraryManager,
+ IDtoService dtoService,
+ ILocalizationManager localizationManager)
+ {
+ _userManager = userManager;
+ _libraryManager = libraryManager;
+ _dtoService = dtoService;
+ _localizationManager = localizationManager;
+ _logger = loggerFactory.CreateLogger<ItemsController>();
+ }
+
+ /// <summary>
+ /// Finds movies and trailers similar to a given trailer.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
+ /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
+ /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
+ /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
+ /// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
+ /// <param name="hasTrailer">Optional filter by items with trailers.</param>
+ /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+ /// <param name="parentIndexNumber">Optional filter by parent index number.</param>
+ /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
+ /// <param name="isHd">Optional filter by items that are HD or not.</param>
+ /// <param name="is4K">Optional filter by items that are 4K or not.</param>
+ /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param>
+ /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param>
+ /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
+ /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
+ /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+ /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
+ /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
+ /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
+ /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
+ /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
+ /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
+ /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param>
+ /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param>
+ /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param>
+ /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
+ /// <param name="searchTerm">Optional. Filter based on a search term.</param>
+ /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
+ /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+ /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+ /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+ /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+ /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
+ /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param>
+ /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
+ /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
+ /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param>
+ /// <param name="enableUserData">Optional, include user data.</param>
+ /// <param name="imageTypeLimit">Optional, the max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+ /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
+ /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+ /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param>
+ /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
+ /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param>
+ /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
+ /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
+ /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
+ /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param>
+ /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param>
+ /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
+ /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param>
+ /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
+ /// <param name="isLocked">Optional filter by items that are locked.</param>
+ /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
+ /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
+ /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
+ /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
+ /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
+ /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
+ /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
+ /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
+ /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param>
+ /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+ /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+ /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+ /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param>
+ /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param>
+ /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+ /// <param name="enableImages">Optional, include image information in output.</param>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
+ [HttpGet("/Trailers")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetTrailers(
+ [FromQuery] Guid? userId,
+ [FromQuery] string? maxOfficialRating,
+ [FromQuery] bool? hasThemeSong,
+ [FromQuery] bool? hasThemeVideo,
+ [FromQuery] bool? hasSubtitles,
+ [FromQuery] bool? hasSpecialFeature,
+ [FromQuery] bool? hasTrailer,
+ [FromQuery] string? adjacentTo,
+ [FromQuery] int? parentIndexNumber,
+ [FromQuery] bool? hasParentalRating,
+ [FromQuery] bool? isHd,
+ [FromQuery] bool? is4K,
+ [FromQuery] string? locationTypes,
+ [FromQuery] string? excludeLocationTypes,
+ [FromQuery] bool? isMissing,
+ [FromQuery] bool? isUnaired,
+ [FromQuery] double? minCommunityRating,
+ [FromQuery] double? minCriticRating,
+ [FromQuery] DateTime? minPremiereDate,
+ [FromQuery] DateTime? minDateLastSaved,
+ [FromQuery] DateTime? minDateLastSavedForUser,
+ [FromQuery] DateTime? maxPremiereDate,
+ [FromQuery] bool? hasOverview,
+ [FromQuery] bool? hasImdbId,
+ [FromQuery] bool? hasTmdbId,
+ [FromQuery] bool? hasTvdbId,
+ [FromQuery] string? excludeItemIds,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] bool? recursive,
+ [FromQuery] string? searchTerm,
+ [FromQuery] string? sortOrder,
+ [FromQuery] string? parentId,
+ [FromQuery] string? fields,
+ [FromQuery] string? excludeItemTypes,
+ [FromQuery] string? filters,
+ [FromQuery] bool? isFavorite,
+ [FromQuery] string? mediaTypes,
+ [FromQuery] string? imageTypes,
+ [FromQuery] string? sortBy,
+ [FromQuery] bool? isPlayed,
+ [FromQuery] string? genres,
+ [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? artists,
+ [FromQuery] string? excludeArtistIds,
+ [FromQuery] string? artistIds,
+ [FromQuery] string? albumArtistIds,
+ [FromQuery] string? contributingArtistIds,
+ [FromQuery] string? albums,
+ [FromQuery] string? albumIds,
+ [FromQuery] string? ids,
+ [FromQuery] string? videoTypes,
+ [FromQuery] string? minOfficialRating,
+ [FromQuery] bool? isLocked,
+ [FromQuery] bool? isPlaceHolder,
+ [FromQuery] bool? hasOfficialRating,
+ [FromQuery] bool? collapseBoxSetItems,
+ [FromQuery] int? minWidth,
+ [FromQuery] int? minHeight,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] bool? is3D,
+ [FromQuery] string? seriesStatus,
+ [FromQuery] string? nameStartsWithOrGreater,
+ [FromQuery] string? nameStartsWith,
+ [FromQuery] string? nameLessThan,
+ [FromQuery] string? studioIds,
+ [FromQuery] string? genreIds,
+ [FromQuery] bool enableTotalRecordCount = true,
+ [FromQuery] bool? enableImages = true)
+ {
+ var includeItemTypes = "Trailer";
+
+ return new ItemsController(
+ _userManager,
+ _libraryManager,
+ _localizationManager,
+ _dtoService,
+ _logger)
+ .GetItems(
+ userId,
+ userId,
+ maxOfficialRating,
+ hasThemeSong,
+ hasThemeVideo,
+ hasSubtitles,
+ hasSpecialFeature,
+ hasTrailer,
+ adjacentTo,
+ parentIndexNumber,
+ hasParentalRating,
+ isHd,
+ is4K,
+ locationTypes,
+ excludeLocationTypes,
+ isMissing,
+ isUnaired,
+ minCommunityRating,
+ minCriticRating,
+ minPremiereDate,
+ minDateLastSaved,
+ minDateLastSavedForUser,
+ maxPremiereDate,
+ hasOverview,
+ hasImdbId,
+ hasTmdbId,
+ hasTvdbId,
+ excludeItemIds,
+ startIndex,
+ limit,
+ recursive,
+ searchTerm,
+ sortOrder,
+ parentId,
+ fields,
+ excludeItemTypes,
+ includeItemTypes,
+ filters,
+ isFavorite,
+ mediaTypes,
+ imageTypes,
+ sortBy,
+ isPlayed,
+ genres,
+ officialRatings,
+ tags,
+ years,
+ enableUserData,
+ imageTypeLimit,
+ enableImageTypes,
+ person,
+ personIds,
+ personTypes,
+ studios,
+ artists,
+ excludeArtistIds,
+ artistIds,
+ albumArtistIds,
+ contributingArtistIds,
+ albums,
+ albumIds,
+ ids,
+ videoTypes,
+ minOfficialRating,
+ isLocked,
+ isPlaceHolder,
+ hasOfficialRating,
+ collapseBoxSetItems,
+ minWidth,
+ minHeight,
+ maxWidth,
+ maxHeight,
+ is3D,
+ seriesStatus,
+ nameStartsWithOrGreater,
+ nameStartsWith,
+ nameLessThan,
+ studioIds,
+ genreIds,
+ enableTotalRecordCount,
+ enableImages);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs
index 6738dd8c8..d54bc10c0 100644
--- a/Jellyfin.Api/Controllers/TvShowsController.cs
+++ b/Jellyfin.Api/Controllers/TvShowsController.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using Jellyfin.Api.Constants;
@@ -69,7 +68,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("NextUp")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetNextUp(
- [FromQuery] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? fields,
@@ -93,12 +92,14 @@ namespace Jellyfin.Api.Controllers
ParentId = parentId,
SeriesId = seriesId,
StartIndex = startIndex,
- UserId = userId,
+ UserId = userId ?? Guid.Empty,
EnableTotalRecordCount = enableTotalRecordCount
},
options);
- var user = _userManager.GetUserById(userId);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
var returnItems = _dtoService.GetBaseItemDtos(result.Items, options, user);
@@ -125,7 +126,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Upcoming")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetUpcomingEpisodes(
- [FromQuery] Guid userId,
+ [FromQuery] Guid? userId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? fields,
@@ -135,7 +136,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] string? enableImageTypes,
[FromQuery] bool? enableUserData)
{
- var user = _userManager.GetUserById(userId);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1);
@@ -190,8 +193,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
- [FromRoute] string seriesId,
- [FromQuery] Guid userId,
+ [FromRoute] string? seriesId,
+ [FromQuery] Guid? userId,
[FromQuery] string? fields,
[FromQuery] int? season,
[FromQuery] string? seasonId,
@@ -206,7 +209,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableUserData,
[FromQuery] string? sortBy)
{
- var user = _userManager.GetUserById(userId);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
List<BaseItem> episodes;
@@ -311,18 +316,20 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
- [FromRoute] string seriesId,
- [FromQuery] Guid userId,
- [FromQuery] string fields,
+ [FromRoute] string? seriesId,
+ [FromQuery] Guid? userId,
+ [FromQuery] string? fields,
[FromQuery] bool? isSpecialSeason,
[FromQuery] bool? isMissing,
- [FromQuery] string adjacentTo,
+ [FromQuery] string? adjacentTo,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery] string? enableImageTypes,
[FromQuery] bool? enableUserData)
{
- var user = _userManager.GetUserById(userId);
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
if (!(_libraryManager.GetItemById(seriesId) is Series series))
{
diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs
index 9f8d564a7..8038ca044 100644
--- a/Jellyfin.Api/Controllers/UserController.cs
+++ b/Jellyfin.Api/Controllers/UserController.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
-using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
@@ -137,14 +136,8 @@ namespace Jellyfin.Api.Controllers
public ActionResult DeleteUser([FromRoute] Guid userId)
{
var user = _userManager.GetUserById(userId);
-
- if (user == null)
- {
- return NotFound("User not found");
- }
-
_sessionManager.RevokeUserTokens(user.Id, null);
- _userManager.DeleteUser(user);
+ _userManager.DeleteUser(userId);
return NoContent();
}
@@ -164,8 +157,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AuthenticationResult>> AuthenticateUser(
[FromRoute, Required] Guid userId,
- [FromQuery, BindRequired] string pw,
- [FromQuery, BindRequired] string password)
+ [FromQuery, BindRequired] string? pw,
+ [FromQuery, BindRequired] string? password)
{
var user = _userManager.GetUserById(userId);
@@ -483,7 +476,7 @@ namespace Jellyfin.Api.Controllers
/// <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] string? enteredUsername)
{
var isLocal = HttpContext.Connection.RemoteIpAddress.Equals(HttpContext.Connection.LocalIpAddress)
|| _networkManager.IsInLocalNetwork(HttpContext.Connection.RemoteIpAddress.ToString());
@@ -501,7 +494,7 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns>
[HttpPost("ForgotPassword/Pin")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody] string pin)
+ public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody] string? pin)
{
var result = await _userManager.RedeemPasswordResetPin(pin).ConfigureAwait(false);
return result;
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
new file mode 100644
index 000000000..cedda3b9d
--- /dev/null
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -0,0 +1,391 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// User library controller.
+ /// </summary>
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ public class UserLibraryController : BaseJellyfinApiController
+ {
+ private readonly IUserManager _userManager;
+ private readonly IUserDataManager _userDataRepository;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IDtoService _dtoService;
+ private readonly IUserViewManager _userViewManager;
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UserLibraryController"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ public UserLibraryController(
+ IUserManager userManager,
+ IUserDataManager userDataRepository,
+ ILibraryManager libraryManager,
+ IDtoService dtoService,
+ IUserViewManager userViewManager,
+ IFileSystem fileSystem)
+ {
+ _userManager = userManager;
+ _userDataRepository = userDataRepository;
+ _libraryManager = libraryManager;
+ _dtoService = dtoService;
+ _userViewManager = userViewManager;
+ _fileSystem = fileSystem;
+ }
+
+ /// <summary>
+ /// Gets an item from a user's library.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Item returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the d item.</returns>
+ [HttpGet("/Users/{userId}/Items/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<BaseItemDto>> GetItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
+ {
+ var user = _userManager.GetUserById(userId);
+
+ var item = itemId.Equals(Guid.Empty)
+ ? _libraryManager.GetUserRootFolder()
+ : _libraryManager.GetItemById(itemId);
+
+ await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
+
+ var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+ }
+
+ /// <summary>
+ /// Gets the root folder from a user's library.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <response code="200">Root folder returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the user's root folder.</returns>
+ [HttpGet("/Users/{userId}/Items/Root")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<BaseItemDto> GetRootFolder([FromRoute] Guid userId)
+ {
+ var user = _userManager.GetUserById(userId);
+ var item = _libraryManager.GetUserRootFolder();
+ var dtoOptions = new DtoOptions().AddClientFields(Request);
+ return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+ }
+
+ /// <summary>
+ /// Gets intros to play before the main media item plays.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Intros returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the intros to play.</returns>
+ [HttpGet("/Users/{userId}/Items/{itemId}/Intros")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task<ActionResult<QueryResult<BaseItemDto>>> GetIntros([FromRoute] Guid userId, [FromRoute] Guid itemId)
+ {
+ var user = _userManager.GetUserById(userId);
+
+ var item = itemId.Equals(Guid.Empty)
+ ? _libraryManager.GetUserRootFolder()
+ : _libraryManager.GetItemById(itemId);
+
+ var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false);
+ var dtoOptions = new DtoOptions().AddClientFields(Request);
+ var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray();
+
+ return new QueryResult<BaseItemDto>
+ {
+ Items = dtos,
+ TotalRecordCount = dtos.Length
+ };
+ }
+
+ /// <summary>
+ /// Marks an item as a favorite.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Item marked as favorite.</response>
+ /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+ [HttpPost("/Users/{userId}/FavoriteItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<UserItemDataDto> MarkFavoriteItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
+ {
+ return MarkFavorite(userId, itemId, true);
+ }
+
+ /// <summary>
+ /// Unmarks item as a favorite.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Item unmarked as favorite.</response>
+ /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+ [HttpDelete("/Users/{userId}/FavoriteItems/{itemId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<UserItemDataDto> UnmarkFavoriteItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
+ {
+ return MarkFavorite(userId, itemId, false);
+ }
+
+ /// <summary>
+ /// Deletes a user's saved personal rating for an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Personal rating removed.</response>
+ /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+ [HttpDelete("/Users/{userId}/Items/{itemId}/Rating")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<UserItemDataDto> DeleteUserItemRating([FromRoute] Guid userId, [FromRoute] Guid itemId)
+ {
+ return UpdateUserItemRatingInternal(userId, itemId, null);
+ }
+
+ /// <summary>
+ /// Updates a user's rating for an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="likes">Whether this <see cref="UpdateUserItemRating" /> is likes.</param>
+ /// <response code="200">Item rating updated.</response>
+ /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+ [HttpPost("/Users/{userId}/Items/{itemId}/Rating")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<UserItemDataDto> UpdateUserItemRating([FromRoute] Guid userId, [FromRoute] Guid itemId, [FromQuery] bool? likes)
+ {
+ return UpdateUserItemRatingInternal(userId, itemId, likes);
+ }
+
+ /// <summary>
+ /// Gets local trailers for an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">An <see cref="OkResult"/> containing the item's local trailers.</response>
+ /// <returns>The items local trailers.</returns>
+ [HttpGet("/Users/{userId}/Items/{itemId}/LocalTrailers")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<BaseItemDto>> GetLocalTrailers([FromRoute] Guid userId, [FromRoute] Guid itemId)
+ {
+ var user = _userManager.GetUserById(userId);
+
+ var item = itemId.Equals(Guid.Empty)
+ ? _libraryManager.GetUserRootFolder()
+ : _libraryManager.GetItemById(itemId);
+
+ var dtoOptions = new DtoOptions().AddClientFields(Request);
+ var dtosExtras = item.GetExtras(new[] { ExtraType.Trailer })
+ .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
+ .ToArray();
+
+ if (item is IHasTrailers hasTrailers)
+ {
+ var trailers = hasTrailers.GetTrailers();
+ var dtosTrailers = _dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item);
+ var allTrailers = new BaseItemDto[dtosExtras.Length + dtosTrailers.Count];
+ dtosExtras.CopyTo(allTrailers, 0);
+ dtosTrailers.CopyTo(allTrailers, dtosExtras.Length);
+ return allTrailers;
+ }
+
+ return dtosExtras;
+ }
+
+ /// <summary>
+ /// Gets special features for an item.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="itemId">Item id.</param>
+ /// <response code="200">Special features returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the special features.</returns>
+ [HttpGet("/Users/{userId}/Items/{itemId}/SpecialFeatures")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<BaseItemDto>> GetSpecialFeatures([FromRoute] Guid userId, [FromRoute] Guid itemId)
+ {
+ var user = _userManager.GetUserById(userId);
+
+ var item = itemId.Equals(Guid.Empty)
+ ? _libraryManager.GetUserRootFolder()
+ : _libraryManager.GetItemById(itemId);
+
+ var dtoOptions = new DtoOptions().AddClientFields(Request);
+
+ return Ok(item
+ .GetExtras(BaseItem.DisplayExtraTypes)
+ .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
+ }
+
+ /// <summary>
+ /// Gets latest media.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, SortName, Studios, Taglines.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
+ /// <param name="isPlayed">Filter by items that are played, or not.</param>
+ /// <param name="enableImages">Optional. include image information in output.</param>
+ /// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="enableUserData">Optional. include user data.</param>
+ /// <param name="limit">Return item limit.</param>
+ /// <param name="groupItems">Whether or not to group items into a parent container.</param>
+ /// <response code="200">Latest media returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the latest media.</returns>
+ [HttpGet("/Users/{userId}/Items/Latest")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<IEnumerable<BaseItemDto>> GetLatestMedia(
+ [FromRoute] Guid userId,
+ [FromQuery] Guid? parentId,
+ [FromQuery] string? fields,
+ [FromQuery] string? includeItemTypes,
+ [FromQuery] bool? isPlayed,
+ [FromQuery] bool? enableImages,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int limit = 20,
+ [FromQuery] bool groupItems = true)
+ {
+ var user = _userManager.GetUserById(userId);
+
+ if (!isPlayed.HasValue)
+ {
+ if (user.HidePlayedInLatest)
+ {
+ isPlayed = false;
+ }
+ }
+
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+
+ var list = _userViewManager.GetLatestItems(
+ new LatestItemsQuery
+ {
+ GroupItems = groupItems,
+ IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+ IsPlayed = isPlayed,
+ Limit = limit,
+ ParentId = parentId ?? Guid.Empty,
+ UserId = userId,
+ }, dtoOptions);
+
+ var dtos = list.Select(i =>
+ {
+ var item = i.Item2[0];
+ var childCount = 0;
+
+ if (i.Item1 != null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum))
+ {
+ item = i.Item1;
+ childCount = i.Item2.Count;
+ }
+
+ var dto = _dtoService.GetBaseItemDto(item, dtoOptions, user);
+
+ dto.ChildCount = childCount;
+
+ return dto;
+ });
+
+ return Ok(dtos);
+ }
+
+ private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
+ {
+ if (item is Person)
+ {
+ var hasMetdata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
+ var performFullRefresh = !hasMetdata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
+
+ if (!hasMetdata)
+ {
+ var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ImageRefreshMode = MetadataRefreshMode.FullRefresh,
+ ForceSave = performFullRefresh
+ };
+
+ await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Marks the favorite.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="isFavorite">if set to <c>true</c> [is favorite].</param>
+ private UserItemDataDto MarkFavorite(Guid userId, Guid itemId, bool isFavorite)
+ {
+ var user = _userManager.GetUserById(userId);
+
+ var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
+
+ // Get the user data for this item
+ var data = _userDataRepository.GetUserData(user, item);
+
+ // Set favorite status
+ data.IsFavorite = isFavorite;
+
+ _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
+
+ return _userDataRepository.GetUserDataDto(item, user);
+ }
+
+ /// <summary>
+ /// Updates the user item rating.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="likes">if set to <c>true</c> [likes].</param>
+ private UserItemDataDto UpdateUserItemRatingInternal(Guid userId, Guid itemId, bool? likes)
+ {
+ var user = _userManager.GetUserById(userId);
+
+ var item = itemId.Equals(Guid.Empty) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId);
+
+ // Get the user data for this item
+ var data = _userDataRepository.GetUserData(user, item);
+
+ data.Likes = likes;
+
+ _userDataRepository.SaveUserData(user, item, data, UserDataSaveReason.UpdateUserRating, CancellationToken.None);
+
+ return _userDataRepository.GetUserDataDto(item, user);
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs
new file mode 100644
index 000000000..f4bd451ef
--- /dev/null
+++ b/Jellyfin.Api/Controllers/UserViewsController.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.UserViewDtos;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Library;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// User views controller.
+ /// </summary>
+ public class UserViewsController : BaseJellyfinApiController
+ {
+ private readonly IUserManager _userManager;
+ private readonly IUserViewManager _userViewManager;
+ private readonly IDtoService _dtoService;
+ private readonly IAuthorizationContext _authContext;
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UserViewsController"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ public UserViewsController(
+ IUserManager userManager,
+ IUserViewManager userViewManager,
+ IDtoService dtoService,
+ IAuthorizationContext authContext,
+ ILibraryManager libraryManager)
+ {
+ _userManager = userManager;
+ _userViewManager = userViewManager;
+ _dtoService = dtoService;
+ _authContext = authContext;
+ _libraryManager = libraryManager;
+ }
+
+ /// <summary>
+ /// Get user views.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="includeExternalContent">Whether or not to include external views such as channels or live tv.</param>
+ /// <param name="presetViews">Preset views.</param>
+ /// <param name="includeHidden">Whether or not to include hidden content.</param>
+ /// <response code="200">User views returned.</response>
+ /// <returns>An <see cref="OkResult"/> containing the user views.</returns>
+ [HttpGet("/Users/{userId}/Views")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetUserViews(
+ [FromRoute] Guid userId,
+ [FromQuery] bool? includeExternalContent,
+ [FromQuery] string? presetViews,
+ [FromQuery] bool includeHidden = false)
+ {
+ var query = new UserViewQuery
+ {
+ UserId = userId,
+ IncludeHidden = includeHidden
+ };
+
+ if (includeExternalContent.HasValue)
+ {
+ query.IncludeExternalContent = includeExternalContent.Value;
+ }
+
+ if (!string.IsNullOrWhiteSpace(presetViews))
+ {
+ query.PresetViews = RequestHelpers.Split(presetViews, ',', true);
+ }
+
+ var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty;
+ if (app.IndexOf("emby rt", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ query.PresetViews = new[] { CollectionType.Movies, CollectionType.TvShows };
+ }
+
+ var folders = _userViewManager.GetUserViews(query);
+
+ var dtoOptions = new DtoOptions().AddClientFields(Request);
+ var fields = dtoOptions.Fields.ToList();
+
+ fields.Add(ItemFields.PrimaryImageAspectRatio);
+ fields.Add(ItemFields.DisplayPreferencesId);
+ fields.Remove(ItemFields.BasicSyncInfo);
+ dtoOptions.Fields = fields.ToArray();
+
+ var user = _userManager.GetUserById(userId);
+
+ var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user))
+ .ToArray();
+
+ return new QueryResult<BaseItemDto>
+ {
+ Items = dtos,
+ TotalRecordCount = dtos.Length
+ };
+ }
+
+ /// <summary>
+ /// Get user view grouping options.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <response code="200">User view grouping options returned.</response>
+ /// <response code="404">User not found.</response>
+ /// <returns>
+ /// An <see cref="OkResult"/> containing the user view grouping options
+ /// or a <see cref="NotFoundResult"/> if user not found.
+ /// </returns>
+ [HttpGet("/Users/{userId}/GroupingOptions")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<IEnumerable<SpecialViewOptionDto>> GetGroupingOptions([FromRoute] Guid userId)
+ {
+ var user = _userManager.GetUserById(userId);
+ if (user == null)
+ {
+ return NotFound();
+ }
+
+ return Ok(_libraryManager.GetUserRootFolder()
+ .GetChildren(user, true)
+ .OfType<Folder>()
+ .Where(UserView.IsEligibleForGrouping)
+ .Select(i => new SpecialViewOptionDto
+ {
+ Name = i.Name,
+ Id = i.Id.ToString("N", CultureInfo.InvariantCulture)
+ })
+ .OrderBy(i => i.Name));
+ }
+ }
+}
diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
index 943ba8af3..eef0a93cd 100644
--- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
+++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
@@ -50,7 +50,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<FileStreamResult>> GetAttachment(
[FromRoute] Guid videoId,
- [FromRoute] string mediaSourceId,
+ [FromRoute] string? mediaSourceId,
[FromRoute] int index)
{
try
diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs
index effe630a9..e2a44427b 100644
--- a/Jellyfin.Api/Controllers/VideosController.cs
+++ b/Jellyfin.Api/Controllers/VideosController.cs
@@ -53,9 +53,11 @@ 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] Guid itemId, [FromQuery] Guid? userId)
{
- var user = !userId.Equals(Guid.Empty) ? _userManager.GetUserById(userId) : null;
+ var user = userId.HasValue && !userId.Equals(Guid.Empty)
+ ? _userManager.GetUserById(userId.Value)
+ : null;
var item = itemId.Equals(Guid.Empty)
? (!userId.Equals(Guid.Empty)
@@ -133,7 +135,7 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
- public ActionResult MergeVersions([FromQuery] string itemIds)
+ public ActionResult MergeVersions([FromQuery] string? itemIds)
{
var items = RequestHelpers.Split(itemIds, ',', true)
.Select(i => _libraryManager.GetItemById(i))
diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs
new file mode 100644
index 000000000..d09b016a9
--- /dev/null
+++ b/Jellyfin.Api/Controllers/YearsController.cs
@@ -0,0 +1,231 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Querying;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jellyfin.Api.Controllers
+{
+ /// <summary>
+ /// Years controller.
+ /// </summary>
+ public class YearsController : BaseJellyfinApiController
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly IDtoService _dtoService;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="YearsController"/> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
+ public YearsController(
+ ILibraryManager libraryManager,
+ IUserManager userManager,
+ IDtoService dtoService)
+ {
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _dtoService = dtoService;
+ }
+
+ /// <summary>
+ /// Get years.
+ /// </summary>
+ /// <param name="startIndex">Skips over a given number of items within the results. Use for paging.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. 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 excluded based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be included based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+ /// <param name="enableUserData">Optional. Include user data.</param>
+ /// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
+ /// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
+ /// <param name="userId">User Id.</param>
+ /// <param name="recursive">Search recursively.</param>
+ /// <param name="enableImages">Optional. Include image information in output.</param>
+ /// <response code="200">Year query returned.</response>
+ /// <returns> A <see cref="QueryResult{BaseItemDto}"/> containing the year result.</returns>
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetYears(
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] string? sortOrder,
+ [FromQuery] string? parentId,
+ [FromQuery] string? fields,
+ [FromQuery] string? excludeItemTypes,
+ [FromQuery] string? includeItemTypes,
+ [FromQuery] string? mediaTypes,
+ [FromQuery] string? sortBy,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery] string? enableImageTypes,
+ [FromQuery] Guid? userId,
+ [FromQuery] bool recursive = true,
+ [FromQuery] bool? enableImages = true)
+ {
+ var dtoOptions = new DtoOptions()
+ .AddItemFields(fields)
+ .AddClientFields(Request)
+ .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);
+ }
+
+ IList<BaseItem> items;
+
+ var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
+ var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
+ var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
+
+ var query = new InternalItemsQuery(user)
+ {
+ ExcludeItemTypes = excludeItemTypesArr,
+ IncludeItemTypes = includeItemTypesArr,
+ MediaTypes = mediaTypesArr,
+ DtoOptions = dtoOptions
+ };
+
+ bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr);
+
+ if (parentItem.IsFolder)
+ {
+ var folder = (Folder)parentItem;
+
+ if (!userId.Equals(Guid.Empty))
+ {
+ items = recursive ? folder.GetRecursiveChildren(user, query).ToList() : folder.GetChildren(user, true).Where(Filter).ToList();
+ }
+ else
+ {
+ items = recursive ? folder.GetRecursiveChildren(Filter) : folder.Children.Where(Filter).ToList();
+ }
+ }
+ else
+ {
+ items = new[] { parentItem }.Where(Filter).ToList();
+ }
+
+ var extractedItems = GetAllItems(items);
+
+ var filteredItems = _libraryManager.Sort(extractedItems, user, RequestHelpers.GetOrderBy(sortBy, sortOrder));
+
+ var ibnItemsArray = filteredItems.ToList();
+
+ IEnumerable<BaseItem> ibnItems = ibnItemsArray;
+
+ var result = new QueryResult<BaseItemDto> { TotalRecordCount = ibnItemsArray.Count };
+
+ if (startIndex.HasValue || limit.HasValue)
+ {
+ if (startIndex.HasValue)
+ {
+ ibnItems = ibnItems.Skip(startIndex.Value);
+ }
+
+ if (limit.HasValue)
+ {
+ ibnItems = ibnItems.Take(limit.Value);
+ }
+ }
+
+ var tuples = ibnItems.Select(i => new Tuple<BaseItem, List<BaseItem>>(i, new List<BaseItem>()));
+
+ var dtos = tuples.Select(i => _dtoService.GetItemByNameDto(i.Item1, dtoOptions, i.Item2, user));
+
+ result.Items = dtos.Where(i => i != null).ToArray();
+
+ return result;
+ }
+
+ /// <summary>
+ /// Gets a year.
+ /// </summary>
+ /// <param name="year">The year.</param>
+ /// <param name="userId">Optional. Filter by user id, and attach user data.</param>
+ /// <response code="200">Year returned.</response>
+ /// <response code="404">Year not found.</response>
+ /// <returns>
+ /// An <see cref="OkResult"/> containing the year,
+ /// or a <see cref="NotFoundResult"/> if year not found.
+ /// </returns>
+ [HttpGet("{year}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<BaseItemDto> GetYear([FromRoute] int year, [FromQuery] Guid? userId)
+ {
+ var item = _libraryManager.GetYear(year);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ var dtoOptions = new DtoOptions()
+ .AddClientFields(Request);
+
+ if (userId.HasValue && !userId.Equals(Guid.Empty))
+ {
+ var user = _userManager.GetUserById(userId.Value);
+ return _dtoService.GetBaseItemDto(item, dtoOptions, user);
+ }
+
+ return _dtoService.GetBaseItemDto(item, dtoOptions);
+ }
+
+ private bool FilterItem(BaseItem f, IReadOnlyCollection<string> excludeItemTypes, IReadOnlyCollection<string> includeItemTypes, IReadOnlyCollection<string> mediaTypes)
+ {
+ // Exclude item types
+ if (excludeItemTypes.Count > 0 && excludeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ // Include item types
+ if (includeItemTypes.Count > 0 && !includeItemTypes.Contains(f.GetType().Name, StringComparer.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ // Include MediaTypes
+ if (mediaTypes.Count > 0 && !mediaTypes.Contains(f.MediaType ?? string.Empty, StringComparer.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private IEnumerable<BaseItem> GetAllItems(IEnumerable<BaseItem> items)
+ {
+ return items
+ .Select(i => i.ProductionYear ?? 0)
+ .Where(i => i > 0)
+ .Distinct()
+ .Select(year => _libraryManager.GetYear(year));
+ }
+ }
+}