using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; using Jellyfin.Api.Helpers; using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.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; /// /// User library controller. /// [Route("")] [Authorize] 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; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. /// Instance of the interface. 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; } /// /// Gets an item from a user's library. /// /// User id. /// Item id. /// Item returned. /// An containing the item. [HttpGet("Items/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { userId = RequestHelpers.GetUserId(User, userId); var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId, user); if (item is null) { return NotFound(); } await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); var dtoOptions = new DtoOptions().AddClientFields(User); return _dtoService.GetBaseItemDto(item, dtoOptions, user); } /// /// Gets an item from a user's library. /// /// User id. /// Item id. /// Item returned. /// An containing the item. [HttpGet("Users/{userId}/Items/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] public Task> GetItemLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => GetItem(userId, itemId); /// /// Gets the root folder from a user's library. /// /// User id. /// Root folder returned. /// An containing the user's root folder. [HttpGet("Items/Root")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult GetRootFolder([FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } var item = _libraryManager.GetUserRootFolder(); var dtoOptions = new DtoOptions().AddClientFields(User); return _dtoService.GetBaseItemDto(item, dtoOptions, user); } /// /// Gets the root folder from a user's library. /// /// User id. /// Root folder returned. /// An containing the user's root folder. [HttpGet("Users/{userId}/Items/Root")] [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] public ActionResult GetRootFolderLegacy( [FromRoute, Required] Guid userId) => GetRootFolder(userId); /// /// Gets intros to play before the main media item plays. /// /// User id. /// Item id. /// Intros returned. /// An containing the intros to play. [HttpGet("Items/{itemId}/Intros")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task>> GetIntros( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { userId = RequestHelpers.GetUserId(User, userId); var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId, user); if (item is null) { return NotFound(); } var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false); var dtoOptions = new DtoOptions().AddClientFields(User); var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray(); return new QueryResult(dtos); } /// /// Gets intros to play before the main media item plays. /// /// User id. /// Item id. /// Intros returned. /// An containing the intros to play. [HttpGet("Users/{userId}/Items/{itemId}/Intros")] [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] public Task>> GetIntrosLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => GetIntros(userId, itemId); /// /// Marks an item as a favorite. /// /// User id. /// Item id. /// Item marked as favorite. /// An containing the . [HttpPost("UserFavoriteItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult MarkFavoriteItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { userId = RequestHelpers.GetUserId(User, userId); var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId, user); if (item is null) { return NotFound(); } return MarkFavorite(user, item, true); } /// /// Marks an item as a favorite. /// /// User id. /// Item id. /// Item marked as favorite. /// An containing the . [HttpPost("Users/{userId}/FavoriteItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] public ActionResult MarkFavoriteItemLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => MarkFavoriteItem(userId, itemId); /// /// Unmarks item as a favorite. /// /// User id. /// Item id. /// Item unmarked as favorite. /// An containing the . [HttpDelete("UserFavoriteItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult UnmarkFavoriteItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { userId = RequestHelpers.GetUserId(User, userId); var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId, user); if (item is null) { return NotFound(); } return MarkFavorite(user, item, false); } /// /// Unmarks item as a favorite. /// /// User id. /// Item id. /// Item unmarked as favorite. /// An containing the . [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] public ActionResult UnmarkFavoriteItemLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => UnmarkFavoriteItem(userId, itemId); /// /// Deletes a user's saved personal rating for an item. /// /// User id. /// Item id. /// Personal rating removed. /// An containing the . [HttpDelete("UserItems/{itemId}/Rating")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult DeleteUserItemRating( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { userId = RequestHelpers.GetUserId(User, userId); var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId, user); if (item is null) { return NotFound(); } return UpdateUserItemRatingInternal(user, item, null); } /// /// Deletes a user's saved personal rating for an item. /// /// User id. /// Item id. /// Personal rating removed. /// An containing the . [HttpDelete("Users/{userId}/Items/{itemId}/Rating")] [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] public ActionResult DeleteUserItemRatingLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => DeleteUserItemRating(userId, itemId); /// /// Updates a user's rating for an item. /// /// User id. /// Item id. /// Whether this is likes. /// Item rating updated. /// An containing the . [HttpPost("UserItems/{itemId}/Rating")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult UpdateUserItemRating( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) { userId = RequestHelpers.GetUserId(User, userId); var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId, user); if (item is null) { return NotFound(); } return UpdateUserItemRatingInternal(user, item, likes); } /// /// Updates a user's rating for an item. /// /// User id. /// Item id. /// Whether this is likes. /// Item rating updated. /// An containing the . [HttpPost("Users/{userId}/Items/{itemId}/Rating")] [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] public ActionResult UpdateUserItemRatingLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId, [FromQuery] bool? likes) => UpdateUserItemRating(userId, itemId, likes); /// /// Gets local trailers for an item. /// /// User id. /// Item id. /// An containing the item's local trailers. /// The items local trailers. [HttpGet("Items/{itemId}/LocalTrailers")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetLocalTrailers( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { userId = RequestHelpers.GetUserId(User, userId); var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId, user); if (item is null) { return NotFound(); } var dtoOptions = new DtoOptions().AddClientFields(User); if (item is IHasTrailers hasTrailers) { var trailers = hasTrailers.LocalTrailers; return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable()); } return Ok(item.GetExtras() .Where(e => e.ExtraType == ExtraType.Trailer) .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); } /// /// Gets local trailers for an item. /// /// User id. /// Item id. /// An containing the item's local trailers. /// The items local trailers. [HttpGet("Users/{userId}/Items/{itemId}/LocalTrailers")] [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] public ActionResult> GetLocalTrailersLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => GetLocalTrailers(userId, itemId); /// /// Gets special features for an item. /// /// User id. /// Item id. /// Special features returned. /// An containing the special features. [HttpGet("Items/{itemId}/SpecialFeatures")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetSpecialFeatures( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { userId = RequestHelpers.GetUserId(User, userId); var user = _userManager.GetUserById(userId.Value); if (user is null) { return NotFound(); } var item = itemId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(itemId, user); if (item is null) { return NotFound(); } var dtoOptions = new DtoOptions().AddClientFields(User); return Ok(item .GetExtras() .Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value)) .Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))); } /// /// Gets special features for an item. /// /// User id. /// Item id. /// Special features returned. /// An containing the special features. [HttpGet("Users/{userId}/Items/{itemId}/SpecialFeatures")] [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] public ActionResult> GetSpecialFeaturesLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => GetSpecialFeatures(userId, itemId); /// /// Gets latest media. /// /// User id. /// Specify this to localize the search to a specific item or folder. Omit to use the root. /// Optional. Specify additional fields of information to return in the output. /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. /// Filter by items that are played, or not. /// Optional. include image information in output. /// Optional. the max number of images to return, per image type. /// Optional. The image types to include in the output. /// Optional. include user data. /// Return item limit. /// Whether or not to group items into a parent container. /// Latest media returned. /// An containing the latest media. [HttpGet("Items/Latest")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetLatestMedia( [FromQuery] Guid? userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool? isPlayed, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] int limit = 20, [FromQuery] bool groupItems = true) { var requestUserId = RequestHelpers.GetUserId(User, userId); var user = _userManager.GetUserById(requestUserId); if (user is null) { return NotFound(); } if (!isPlayed.HasValue) { if (user.HidePlayedInLatest) { isPlayed = false; } } var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); var list = _userViewManager.GetLatestItems( new LatestItemsQuery { GroupItems = groupItems, IncludeItemTypes = includeItemTypes, IsPlayed = isPlayed, Limit = limit, ParentId = parentId ?? Guid.Empty, UserId = requestUserId, }, dtoOptions); var dtos = list.Select(i => { var item = i.Item2[0]; var childCount = 0; if (i.Item1 is not 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); } /// /// Gets latest media. /// /// User id. /// Specify this to localize the search to a specific item or folder. Omit to use the root. /// Optional. Specify additional fields of information to return in the output. /// Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited. /// Filter by items that are played, or not. /// Optional. include image information in output. /// Optional. the max number of images to return, per image type. /// Optional. The image types to include in the output. /// Optional. include user data. /// Return item limit. /// Whether or not to group items into a parent container. /// Latest media returned. /// An containing the latest media. [HttpGet("Users/{userId}/Items/Latest")] [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] public ActionResult> GetLatestMediaLegacy( [FromRoute, Required] Guid userId, [FromQuery] Guid? parentId, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] BaseItemKind[] includeItemTypes, [FromQuery] bool? isPlayed, [FromQuery] bool? enableImages, [FromQuery] int? imageTypeLimit, [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes, [FromQuery] bool? enableUserData, [FromQuery] int limit = 20, [FromQuery] bool groupItems = true) => GetLatestMedia( userId, parentId, fields, includeItemTypes, isPlayed, enableImages, imageTypeLimit, enableImageTypes, enableUserData, limit, groupItems); 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); } } } /// /// Marks the favorite. /// /// The user. /// The item. /// if set to true [is favorite]. private UserItemDataDto MarkFavorite(User user, BaseItem item, bool isFavorite) { // 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); } /// /// Updates the user item rating. /// /// The user. /// The item. /// if set to true [likes]. private UserItemDataDto UpdateUserItemRatingInternal(User user, BaseItem item, bool? likes) { // 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); } }