mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-08-06 22:27:07 +02:00
Merge 8abcaee846
into 43a955dded
This commit is contained in:
commit
aeeb0c22b9
320
Jellyfin.Api/Controllers/HomeSectionController.cs
Normal file
320
Jellyfin.Api/Controllers/HomeSectionController.cs
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Linq;
|
||||||
|
using Jellyfin.Api.Models.HomeSectionDto;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Querying;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Controllers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Home Section controller.
|
||||||
|
/// </summary>
|
||||||
|
[Route("Users/{userId}/HomeSections")]
|
||||||
|
[Authorize]
|
||||||
|
public class HomeSectionController : BaseJellyfinApiController
|
||||||
|
{
|
||||||
|
private readonly IHomeSectionManager _homeSectionManager;
|
||||||
|
private readonly IUserManager _userManager;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly IDtoService _dtoService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="HomeSectionController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="homeSectionManager">Instance of the <see cref="IHomeSectionManager"/> 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>
|
||||||
|
public HomeSectionController(
|
||||||
|
IHomeSectionManager homeSectionManager,
|
||||||
|
IUserManager userManager,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
IDtoService dtoService)
|
||||||
|
{
|
||||||
|
_homeSectionManager = homeSectionManager;
|
||||||
|
_userManager = userManager;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_dtoService = dtoService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all home sections.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">User id.</param>
|
||||||
|
/// <response code="200">Home sections retrieved.</response>
|
||||||
|
/// <returns>An <see cref="IEnumerable{EnrichedHomeSectionDto}"/> containing the home sections.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public ActionResult<List<EnrichedHomeSectionDto>> GetHomeSections([FromRoute, Required] Guid userId)
|
||||||
|
{
|
||||||
|
var sections = _homeSectionManager.GetHomeSections(userId);
|
||||||
|
var result = new List<EnrichedHomeSectionDto>();
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return NotFound("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var section in sections)
|
||||||
|
{
|
||||||
|
var enrichedSection = new EnrichedHomeSectionDto
|
||||||
|
{
|
||||||
|
Id = null, // We'll need to retrieve the ID from the database
|
||||||
|
SectionOptions = section,
|
||||||
|
Items = GetItemsForSection(userId, section)
|
||||||
|
};
|
||||||
|
|
||||||
|
result.Add(enrichedSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get home section.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">User id.</param>
|
||||||
|
/// <param name="sectionId">Section id.</param>
|
||||||
|
/// <response code="200">Home section retrieved.</response>
|
||||||
|
/// <response code="404">Home section not found.</response>
|
||||||
|
/// <returns>An <see cref="EnrichedHomeSectionDto"/> containing the home section.</returns>
|
||||||
|
[HttpGet("{sectionId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public ActionResult<EnrichedHomeSectionDto> GetHomeSection([FromRoute, Required] Guid userId, [FromRoute, Required] Guid sectionId)
|
||||||
|
{
|
||||||
|
var section = _homeSectionManager.GetHomeSection(userId, sectionId);
|
||||||
|
if (section == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return NotFound("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new EnrichedHomeSectionDto
|
||||||
|
{
|
||||||
|
Id = sectionId,
|
||||||
|
SectionOptions = section,
|
||||||
|
Items = GetItemsForSection(userId, section)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new home section.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">User id.</param>
|
||||||
|
/// <param name="dto">The home section dto.</param>
|
||||||
|
/// <response code="201">Home section created.</response>
|
||||||
|
/// <returns>An <see cref="HomeSectionDto"/> containing the new home section.</returns>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
public ActionResult<HomeSectionDto> CreateHomeSection([FromRoute, Required] Guid userId, [FromBody, Required] HomeSectionDto dto)
|
||||||
|
{
|
||||||
|
var sectionId = _homeSectionManager.CreateHomeSection(userId, dto.SectionOptions);
|
||||||
|
_homeSectionManager.SaveChanges();
|
||||||
|
|
||||||
|
dto.Id = sectionId;
|
||||||
|
return CreatedAtAction(nameof(GetHomeSection), new { userId, sectionId }, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update a home section.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">User id.</param>
|
||||||
|
/// <param name="sectionId">Section id.</param>
|
||||||
|
/// <param name="dto">The home section dto.</param>
|
||||||
|
/// <response code="204">Home section updated.</response>
|
||||||
|
/// <response code="404">Home section not found.</response>
|
||||||
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
|
[HttpPut("{sectionId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public ActionResult UpdateHomeSection(
|
||||||
|
[FromRoute, Required] Guid userId,
|
||||||
|
[FromRoute, Required] Guid sectionId,
|
||||||
|
[FromBody, Required] HomeSectionDto dto)
|
||||||
|
{
|
||||||
|
var success = _homeSectionManager.UpdateHomeSection(userId, sectionId, dto.SectionOptions);
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_homeSectionManager.SaveChanges();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete a home section.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">User id.</param>
|
||||||
|
/// <param name="sectionId">Section id.</param>
|
||||||
|
/// <response code="204">Home section deleted.</response>
|
||||||
|
/// <response code="404">Home section not found.</response>
|
||||||
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
|
[HttpDelete("{sectionId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public ActionResult DeleteHomeSection([FromRoute, Required] Guid userId, [FromRoute, Required] Guid sectionId)
|
||||||
|
{
|
||||||
|
var success = _homeSectionManager.DeleteHomeSection(userId, sectionId);
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
_homeSectionManager.SaveChanges();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<BaseItemDto> GetItemsForSection(Guid userId, HomeSectionOptions options)
|
||||||
|
{
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (options.SectionType)
|
||||||
|
{
|
||||||
|
case HomeSectionType.None:
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
case HomeSectionType.SmallLibraryTiles:
|
||||||
|
return GetLibraryTilesHomeSectionItems(userId, true);
|
||||||
|
case HomeSectionType.LibraryButtons:
|
||||||
|
return GetLibraryTilesHomeSectionItems(userId, false);
|
||||||
|
// TODO: Implement GetActiveRecordingsHomeSectionItems
|
||||||
|
case HomeSectionType.ActiveRecordings:
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
// TODO: Implement GetResumeItemsHomeSectionItems
|
||||||
|
case HomeSectionType.Resume:
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
// TODO: Implement GetResumeAudioHomeSectionItems
|
||||||
|
case HomeSectionType.ResumeAudio:
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
case HomeSectionType.LatestMedia:
|
||||||
|
return GetLatestMediaHomeSectionItems(userId, options.MaxItems);
|
||||||
|
// TODO: Implement GetNextUpHomeSectionItems
|
||||||
|
case HomeSectionType.NextUp:
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
// TODO: Implement GetLiveTvHomeSectionItems
|
||||||
|
case HomeSectionType.LiveTv:
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
// TODO: Implement ResumeBookHomeSectionItems
|
||||||
|
case HomeSectionType.ResumeBook:
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
// Major TODO: Implement GetPinnedCollectionHomeSectionItems and add HomeSectionType.PinnedCollection
|
||||||
|
// See example at https://github.com/johnpc/jellyfin-plugin-home-sections/blob/main/Jellyfin.Plugin.HomeSections/Api/HomeSectionsController.cs
|
||||||
|
// Question: what should I do in the case of an unexpected HomeSectionType? Throw an exception?
|
||||||
|
default:
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<BaseItemDto> GetLatestMediaHomeSectionItems(Guid userId, int maxItems)
|
||||||
|
{
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = new InternalItemsQuery(user)
|
||||||
|
{
|
||||||
|
Recursive = true,
|
||||||
|
Limit = maxItems,
|
||||||
|
IsVirtualItem = false,
|
||||||
|
OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }
|
||||||
|
};
|
||||||
|
|
||||||
|
var items = _libraryManager.GetItemsResult(query);
|
||||||
|
|
||||||
|
return items.Items
|
||||||
|
.Where(i => i != null && (i is Movie || i is Series || i is Episode))
|
||||||
|
.Select(i =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _dtoService.GetBaseItemDto(i, new DtoOptions(), user);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log the error but don't crash
|
||||||
|
System.Diagnostics.Debug.WriteLine($"Error converting item {i.Id} to DTO: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.Where(dto => dto != null)
|
||||||
|
.Cast<BaseItemDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<BaseItemDto> GetLibraryTilesHomeSectionItems(Guid userId, bool smallTiles = false)
|
||||||
|
{
|
||||||
|
var user = _userManager.GetUserById(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user's view items (libraries)
|
||||||
|
var folders = _libraryManager.GetUserRootFolder()
|
||||||
|
.GetChildren(user, true)
|
||||||
|
.Where(i => i.IsFolder && !i.IsHidden)
|
||||||
|
.OrderBy(i => i.SortName)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Convert to DTOs with appropriate options
|
||||||
|
var options = new DtoOptions
|
||||||
|
{
|
||||||
|
// For small tiles, we might want to limit the fields returned
|
||||||
|
// to make the response smaller
|
||||||
|
Fields = smallTiles
|
||||||
|
? new[] { ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId }
|
||||||
|
: new[]
|
||||||
|
{
|
||||||
|
ItemFields.PrimaryImageAspectRatio,
|
||||||
|
ItemFields.DisplayPreferencesId,
|
||||||
|
ItemFields.Overview,
|
||||||
|
ItemFields.ChildCount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return folders
|
||||||
|
.Select(i =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _dtoService.GetBaseItemDto(i, options, user);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.Where(dto => dto != null)
|
||||||
|
.Cast<BaseItemDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
Jellyfin.Api/Models/HomeSectionDto/EnrichedHomeSectionDto.cs
Normal file
28
Jellyfin.Api/Models/HomeSectionDto/EnrichedHomeSectionDto.cs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Models.HomeSectionDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Home section dto with items.
|
||||||
|
/// </summary>
|
||||||
|
public class EnrichedHomeSectionDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the section options.
|
||||||
|
/// </summary>
|
||||||
|
public HomeSectionOptions SectionOptions { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the items.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<BaseItemDto> Items { get; set; } = Array.Empty<BaseItemDto>();
|
||||||
|
}
|
||||||
|
}
|
23
Jellyfin.Api/Models/HomeSectionDto/HomeSectionDto.cs
Normal file
23
Jellyfin.Api/Models/HomeSectionDto/HomeSectionDto.cs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Models.HomeSectionDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Home section DTO.
|
||||||
|
/// </summary>
|
||||||
|
public class HomeSectionDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the id.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the section options.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public HomeSectionOptions SectionOptions { get; set; } = new HomeSectionOptions();
|
||||||
|
}
|
||||||
|
}
|
180
Jellyfin.Server.Implementations/Users/HomeSectionManager.cs
Normal file
180
Jellyfin.Server.Implementations/Users/HomeSectionManager.cs
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Users
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the storage and retrieval of home sections through Entity Framework.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HomeSectionManager : IHomeSectionManager, IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly JellyfinDbContext _dbContext;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="HomeSectionManager"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dbContextFactory">The database context factory.</param>
|
||||||
|
public HomeSectionManager(IDbContextFactory<JellyfinDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
_dbContext = dbContextFactory.CreateDbContext();
|
||||||
|
// QUESTION FOR MAINTAINERS: How do I handle the db migration?
|
||||||
|
// I'm sure you don't want the table to be created lazily like this.
|
||||||
|
EnsureTableExists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures that the UserHomeSections table exists in the database.
|
||||||
|
/// </summary>
|
||||||
|
private void EnsureTableExists()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check if table exists by attempting to query it
|
||||||
|
_dbContext.Database.ExecuteSqlRaw("SELECT 1 FROM UserHomeSections LIMIT 1");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Table doesn't exist, create it
|
||||||
|
_dbContext.Database.ExecuteSqlRaw(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS UserHomeSections (
|
||||||
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
UserId TEXT NOT NULL,
|
||||||
|
SectionId TEXT NOT NULL,
|
||||||
|
Name TEXT NOT NULL,
|
||||||
|
SectionType INTEGER NOT NULL,
|
||||||
|
Priority INTEGER NOT NULL,
|
||||||
|
MaxItems INTEGER NOT NULL,
|
||||||
|
SortOrder INTEGER NOT NULL,
|
||||||
|
SortBy INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS IX_UserHomeSections_UserId_SectionId ON UserHomeSections(UserId, SectionId);
|
||||||
|
");
|
||||||
|
|
||||||
|
// Add the migration record to __EFMigrationsHistory
|
||||||
|
_dbContext.Database.ExecuteSqlRaw(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS __EFMigrationsHistory (
|
||||||
|
MigrationId TEXT NOT NULL CONSTRAINT PK___EFMigrationsHistory PRIMARY KEY,
|
||||||
|
ProductVersion TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO __EFMigrationsHistory (MigrationId, ProductVersion)
|
||||||
|
VALUES ('20250331000000_AddUserHomeSections', '3.1.0');
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IList<HomeSectionOptions> GetHomeSections(Guid userId)
|
||||||
|
{
|
||||||
|
return _dbContext.UserHomeSections
|
||||||
|
.Where(section => section.UserId.Equals(userId))
|
||||||
|
.OrderBy(section => section.Priority)
|
||||||
|
.Select(section => new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = section.Name,
|
||||||
|
SectionType = section.SectionType,
|
||||||
|
Priority = section.Priority,
|
||||||
|
MaxItems = section.MaxItems,
|
||||||
|
SortOrder = section.SortOrder,
|
||||||
|
SortBy = (Jellyfin.Database.Implementations.Enums.SortOrder)section.SortBy
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public HomeSectionOptions? GetHomeSection(Guid userId, Guid sectionId)
|
||||||
|
{
|
||||||
|
var section = _dbContext.UserHomeSections
|
||||||
|
.FirstOrDefault(section => section.UserId.Equals(userId) && section.SectionId.Equals(sectionId));
|
||||||
|
|
||||||
|
if (section == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = section.Name,
|
||||||
|
SectionType = section.SectionType,
|
||||||
|
Priority = section.Priority,
|
||||||
|
MaxItems = section.MaxItems,
|
||||||
|
SortOrder = section.SortOrder,
|
||||||
|
SortBy = (Jellyfin.Database.Implementations.Enums.SortOrder)section.SortBy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid CreateHomeSection(Guid userId, HomeSectionOptions options)
|
||||||
|
{
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
var section = new UserHomeSection
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
SectionId = sectionId,
|
||||||
|
Name = options.Name,
|
||||||
|
SectionType = options.SectionType,
|
||||||
|
Priority = options.Priority,
|
||||||
|
MaxItems = options.MaxItems,
|
||||||
|
SortOrder = options.SortOrder,
|
||||||
|
SortBy = (int)options.SortBy
|
||||||
|
};
|
||||||
|
|
||||||
|
_dbContext.UserHomeSections.Add(section);
|
||||||
|
return sectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool UpdateHomeSection(Guid userId, Guid sectionId, HomeSectionOptions options)
|
||||||
|
{
|
||||||
|
var section = _dbContext.UserHomeSections
|
||||||
|
.FirstOrDefault(section => section.UserId.Equals(userId) && section.SectionId.Equals(sectionId));
|
||||||
|
|
||||||
|
if (section == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.Name = options.Name;
|
||||||
|
section.SectionType = options.SectionType;
|
||||||
|
section.Priority = options.Priority;
|
||||||
|
section.MaxItems = options.MaxItems;
|
||||||
|
section.SortOrder = options.SortOrder;
|
||||||
|
section.SortBy = (int)options.SortBy;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool DeleteHomeSection(Guid userId, Guid sectionId)
|
||||||
|
{
|
||||||
|
var section = _dbContext.UserHomeSections
|
||||||
|
.FirstOrDefault(section => section.UserId.Equals(userId) && section.SectionId.Equals(sectionId));
|
||||||
|
|
||||||
|
if (section == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dbContext.UserHomeSections.Remove(section);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SaveChanges()
|
||||||
|
{
|
||||||
|
_dbContext.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _dbContext.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -108,6 +108,9 @@ namespace Jellyfin.Server
|
|||||||
})
|
})
|
||||||
.ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
|
.ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate);
|
||||||
|
|
||||||
|
// Register the HomeSectionManager
|
||||||
|
services.AddScoped<MediaBrowser.Controller.IHomeSectionManager, Jellyfin.Server.Implementations.Users.HomeSectionManager>();
|
||||||
|
|
||||||
services.AddHttpClient(NamedClient.MusicBrainz, c =>
|
services.AddHttpClient(NamedClient.MusicBrainz, c =>
|
||||||
{
|
{
|
||||||
c.DefaultRequestHeaders.UserAgent.Add(productHeader);
|
c.DefaultRequestHeaders.UserAgent.Add(productHeader);
|
||||||
|
57
MediaBrowser.Controller/IHomeSectionManager.cs
Normal file
57
MediaBrowser.Controller/IHomeSectionManager.cs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for managing home sections.
|
||||||
|
/// </summary>
|
||||||
|
public interface IHomeSectionManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all home sections for a user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user id.</param>
|
||||||
|
/// <returns>A list of home section options.</returns>
|
||||||
|
IList<HomeSectionOptions> GetHomeSections(Guid userId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a specific home section for a user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user id.</param>
|
||||||
|
/// <param name="sectionId">The section id.</param>
|
||||||
|
/// <returns>The home section options, or null if not found.</returns>
|
||||||
|
HomeSectionOptions? GetHomeSection(Guid userId, Guid sectionId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new home section for a user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user id.</param>
|
||||||
|
/// <param name="options">The home section options.</param>
|
||||||
|
/// <returns>The id of the newly created section.</returns>
|
||||||
|
Guid CreateHomeSection(Guid userId, HomeSectionOptions options);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates a home section for a user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user id.</param>
|
||||||
|
/// <param name="sectionId">The section id.</param>
|
||||||
|
/// <param name="options">The updated home section options.</param>
|
||||||
|
/// <returns>True if the section was updated, false if it was not found.</returns>
|
||||||
|
bool UpdateHomeSection(Guid userId, Guid sectionId, HomeSectionOptions options);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a home section for a user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user id.</param>
|
||||||
|
/// <param name="sectionId">The section id.</param>
|
||||||
|
/// <returns>True if the section was deleted, false if it was not found.</returns>
|
||||||
|
bool DeleteHomeSection(Guid userId, Guid sectionId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves changes to the database.
|
||||||
|
/// </summary>
|
||||||
|
void SaveChanges();
|
||||||
|
}
|
||||||
|
}
|
55
MediaBrowser.Model/Configuration/HomeSectionOptions.cs
Normal file
55
MediaBrowser.Model/Configuration/HomeSectionOptions.cs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System.ComponentModel;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Model.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Options for a specific home section.
|
||||||
|
/// </summary>
|
||||||
|
public class HomeSectionOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="HomeSectionOptions"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public HomeSectionOptions()
|
||||||
|
{
|
||||||
|
Name = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the section.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the section.
|
||||||
|
/// </summary>
|
||||||
|
public HomeSectionType SectionType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the priority/order of this section (lower numbers appear first).
|
||||||
|
/// </summary>
|
||||||
|
[DefaultValue(0)]
|
||||||
|
public int Priority { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum number of items to display in the section.
|
||||||
|
/// </summary>
|
||||||
|
[DefaultValue(10)]
|
||||||
|
public int MaxItems { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the sort order for items in this section.
|
||||||
|
/// </summary>
|
||||||
|
[DefaultValue(SortOrder.Ascending)]
|
||||||
|
public SortOrder SortOrder { get; set; } = SortOrder.Ascending;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets how items should be sorted in this section.
|
||||||
|
/// </summary>
|
||||||
|
[DefaultValue(SortOrder.Ascending)]
|
||||||
|
public SortOrder SortBy { get; set; } = SortOrder.Ascending;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
|
||||||
|
namespace Jellyfin.Database.Implementations.Entities
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// An entity representing a user's home section.
|
||||||
|
/// </summary>
|
||||||
|
public class UserHomeSection
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Id.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Identity. Required.
|
||||||
|
/// </remarks>
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
public int Id { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the user Id.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Required.
|
||||||
|
/// </remarks>
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the section Id.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Required.
|
||||||
|
/// </remarks>
|
||||||
|
public Guid SectionId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the name of the section.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Required. Max Length = 64.
|
||||||
|
/// </remarks>
|
||||||
|
[MaxLength(64)]
|
||||||
|
[StringLength(64)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the type of the section.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Required.
|
||||||
|
/// </remarks>
|
||||||
|
public HomeSectionType SectionType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the priority/order of this section.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Required.
|
||||||
|
/// </remarks>
|
||||||
|
public int Priority { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum number of items to display in the section.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Required.
|
||||||
|
/// </remarks>
|
||||||
|
public int MaxItems { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the sort order for items in this section.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Required.
|
||||||
|
/// </remarks>
|
||||||
|
public SortOrder SortOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets how items should be sorted in this section.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Required.
|
||||||
|
/// </remarks>
|
||||||
|
public int SortBy { get; set; }
|
||||||
|
}
|
||||||
|
}
|
@ -53,6 +53,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<DisplayPreferences> DisplayPreferences => Set<DisplayPreferences>();
|
public DbSet<DisplayPreferences> DisplayPreferences => Set<DisplayPreferences>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the <see cref="DbSet{TEntity}"/> containing the user home sections.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<UserHomeSection> UserHomeSections => Set<UserHomeSection>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the <see cref="DbSet{TEntity}"/> containing the image infos.
|
/// Gets the <see cref="DbSet{TEntity}"/> containing the image infos.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
namespace Jellyfin.Database.Implementations.ModelConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the UserHomeSection entity.
|
||||||
|
/// </summary>
|
||||||
|
public class UserHomeSectionConfiguration : IEntityTypeConfiguration<UserHomeSection>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Configure(EntityTypeBuilder<UserHomeSection> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("UserHomeSections");
|
||||||
|
|
||||||
|
builder.HasKey(e => e.Id);
|
||||||
|
|
||||||
|
builder.Property(e => e.UserId)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(e => e.SectionId)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(e => e.Name)
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64);
|
||||||
|
|
||||||
|
builder.Property(e => e.SectionType)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(e => e.Priority)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(e => e.MaxItems)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(e => e.SortOrder)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(e => e.SortBy)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
// Create a unique index on UserId + SectionId
|
||||||
|
builder.HasIndex(e => new { e.UserId, e.SectionId })
|
||||||
|
.IsUnique();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
namespace Jellyfin.Database.Providers.Sqlite.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Migration to add UserHomeSections table.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddUserHomeSections : Migration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the operations that will migrate the database 'up'.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="migrationBuilder">The migration builder.</param>
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UserHomeSections",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
UserId = table.Column<string>(nullable: false),
|
||||||
|
SectionId = table.Column<string>(nullable: false),
|
||||||
|
Name = table.Column<string>(nullable: false),
|
||||||
|
SectionType = table.Column<int>(nullable: false),
|
||||||
|
Priority = table.Column<int>(nullable: false),
|
||||||
|
MaxItems = table.Column<int>(nullable: false),
|
||||||
|
SortOrder = table.Column<int>(nullable: false),
|
||||||
|
SortBy = table.Column<int>(nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UserHomeSections", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserHomeSections_UserId_SectionId",
|
||||||
|
table: "UserHomeSections",
|
||||||
|
columns: new[] { "UserId", "SectionId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the operations that will migrate the database 'down'.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="migrationBuilder">The migration builder.</param>
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UserHomeSections");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,264 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Jellyfin.Api.Controllers;
|
||||||
|
using Jellyfin.Api.Models.HomeSectionDto;
|
||||||
|
using Jellyfin.Api.Results;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Dto;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Tests.Controllers
|
||||||
|
{
|
||||||
|
public class HomeSectionControllerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IHomeSectionManager> _mockHomeSectionManager;
|
||||||
|
private readonly Mock<IUserManager> _mockUserManager;
|
||||||
|
private readonly Mock<ILibraryManager> _mockLibraryManager;
|
||||||
|
private readonly Mock<IDtoService> _mockDtoService;
|
||||||
|
private readonly HomeSectionController _controller;
|
||||||
|
private readonly Guid _userId = Guid.NewGuid();
|
||||||
|
|
||||||
|
public HomeSectionControllerTests()
|
||||||
|
{
|
||||||
|
_mockHomeSectionManager = new Mock<IHomeSectionManager>();
|
||||||
|
_mockUserManager = new Mock<IUserManager>();
|
||||||
|
_mockLibraryManager = new Mock<ILibraryManager>();
|
||||||
|
_mockDtoService = new Mock<IDtoService>();
|
||||||
|
|
||||||
|
_controller = new HomeSectionController(
|
||||||
|
_mockHomeSectionManager.Object,
|
||||||
|
_mockUserManager.Object,
|
||||||
|
_mockLibraryManager.Object,
|
||||||
|
_mockDtoService.Object);
|
||||||
|
|
||||||
|
// Setup user manager to return a non-null user for the test user ID
|
||||||
|
var mockUser = new Mock<User>();
|
||||||
|
_mockUserManager.Setup(m => m.GetUserById(_userId))
|
||||||
|
.Returns(mockUser.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHomeSections_ReturnsOkResult_WithListOfSections()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sections = new List<HomeSectionOptions>
|
||||||
|
{
|
||||||
|
new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Test Section 1",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 1,
|
||||||
|
MaxItems = 10,
|
||||||
|
SortOrder = SortOrder.Descending,
|
||||||
|
SortBy = SortOrder.Ascending
|
||||||
|
},
|
||||||
|
new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Test Section 2",
|
||||||
|
SectionType = HomeSectionType.NextUp,
|
||||||
|
Priority = 2,
|
||||||
|
MaxItems = 5,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = SortOrder.Descending
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockHomeSectionManager.Setup(m => m.GetHomeSections(_userId))
|
||||||
|
.Returns(sections);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _controller.GetHomeSections(_userId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var okResult = Assert.IsAssignableFrom<OkObjectResult>(result.Result);
|
||||||
|
var returnValue = Assert.IsType<List<EnrichedHomeSectionDto>>(okResult.Value);
|
||||||
|
Assert.Equal(2, returnValue.Count);
|
||||||
|
Assert.Equal("Test Section 1", returnValue[0].SectionOptions.Name);
|
||||||
|
Assert.Equal("Test Section 2", returnValue[1].SectionOptions.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHomeSection_WithValidId_ReturnsOkResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
var section = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Test Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 1,
|
||||||
|
MaxItems = 10,
|
||||||
|
SortOrder = SortOrder.Descending,
|
||||||
|
SortBy = SortOrder.Ascending
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockHomeSectionManager.Setup(m => m.GetHomeSection(_userId, sectionId))
|
||||||
|
.Returns(section);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _controller.GetHomeSection(_userId, sectionId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var okResult = Assert.IsAssignableFrom<OkObjectResult>(result.Result);
|
||||||
|
var returnValue = Assert.IsType<EnrichedHomeSectionDto>(okResult.Value);
|
||||||
|
Assert.Equal("Test Section", returnValue.SectionOptions.Name);
|
||||||
|
Assert.Equal(sectionId, returnValue.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHomeSection_WithInvalidId_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
_mockHomeSectionManager.Setup(m => m.GetHomeSection(_userId, sectionId))
|
||||||
|
.Returns((HomeSectionOptions?)null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _controller.GetHomeSection(_userId, sectionId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<NotFoundResult>(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateHomeSection_ReturnsCreatedAtAction()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
var dto = new HomeSectionDto
|
||||||
|
{
|
||||||
|
SectionOptions = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "New Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 3,
|
||||||
|
MaxItems = 15,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = SortOrder.Ascending
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockHomeSectionManager.Setup(m => m.CreateHomeSection(_userId, dto.SectionOptions))
|
||||||
|
.Returns(sectionId);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _controller.CreateHomeSection(_userId, dto);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(result.Result);
|
||||||
|
var returnValue = Assert.IsType<HomeSectionDto>(createdAtActionResult.Value);
|
||||||
|
Assert.Equal("New Section", returnValue.SectionOptions.Name);
|
||||||
|
Assert.Equal(sectionId, returnValue.Id);
|
||||||
|
Assert.Equal("GetHomeSection", createdAtActionResult.ActionName);
|
||||||
|
|
||||||
|
// Check if RouteValues is not null before accessing its elements
|
||||||
|
Assert.NotNull(createdAtActionResult.RouteValues);
|
||||||
|
if (createdAtActionResult.RouteValues != null)
|
||||||
|
{
|
||||||
|
Assert.Equal(_userId, createdAtActionResult.RouteValues["userId"]);
|
||||||
|
Assert.Equal(sectionId, createdAtActionResult.RouteValues["sectionId"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateHomeSection_WithValidId_ReturnsNoContent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
var dto = new HomeSectionDto
|
||||||
|
{
|
||||||
|
SectionOptions = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Updated Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 3,
|
||||||
|
MaxItems = 15,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = SortOrder.Ascending
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockHomeSectionManager.Setup(m => m.UpdateHomeSection(_userId, sectionId, dto.SectionOptions))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _controller.UpdateHomeSection(_userId, sectionId, dto);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<NoContentResult>(result);
|
||||||
|
_mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateHomeSection_WithInvalidId_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
var dto = new HomeSectionDto
|
||||||
|
{
|
||||||
|
SectionOptions = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Updated Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 3,
|
||||||
|
MaxItems = 15,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = SortOrder.Ascending
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockHomeSectionManager.Setup(m => m.UpdateHomeSection(_userId, sectionId, dto.SectionOptions))
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _controller.UpdateHomeSection(_userId, sectionId, dto);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<NotFoundResult>(result);
|
||||||
|
_mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteHomeSection_WithValidId_ReturnsNoContent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
_mockHomeSectionManager.Setup(m => m.DeleteHomeSection(_userId, sectionId))
|
||||||
|
.Returns(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _controller.DeleteHomeSection(_userId, sectionId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<NoContentResult>(result);
|
||||||
|
_mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteHomeSection_WithInvalidId_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
_mockHomeSectionManager.Setup(m => m.DeleteHomeSection(_userId, sectionId))
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _controller.DeleteHomeSection(_userId, sectionId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.IsType<NotFoundResult>(result);
|
||||||
|
_mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
202
tests/Jellyfin.Api.Tests/Integration/HomeSectionApiTests.cs
Normal file
202
tests/Jellyfin.Api.Tests/Integration/HomeSectionApiTests.cs
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Api.Models.HomeSectionDto;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Tests.Integration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the Home Section API.
|
||||||
|
/// These tests require a running Jellyfin server and should be run in a controlled environment.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HomeSectionApiTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly Guid _userId = Guid.Parse("38a9e9be-b2a6-4790-85a3-62a01ca06dec"); // Test user ID
|
||||||
|
private readonly List<Guid> _createdSectionIds = new List<Guid>();
|
||||||
|
|
||||||
|
public HomeSectionApiTests()
|
||||||
|
{
|
||||||
|
// Setup HttpClient with base address pointing to your test server
|
||||||
|
_client = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("http://localhost:8096/")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Integration test - requires running server")]
|
||||||
|
public async Task GetHomeSections_ReturnsSuccessStatusCode()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var response = await _client.GetAsync($"Users/{_userId}/HomeSections");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var sections = await response.Content.ReadFromJsonAsync<List<HomeSectionDto>>();
|
||||||
|
Assert.NotNull(sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Integration test - requires running server")]
|
||||||
|
public async Task CreateAndGetHomeSection_ReturnsCreatedSection()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var newSection = new HomeSectionDto
|
||||||
|
{
|
||||||
|
SectionOptions = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Integration Test Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 100,
|
||||||
|
MaxItems = 8,
|
||||||
|
SortOrder = SortOrder.Descending,
|
||||||
|
SortBy = SortOrder.Ascending
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act - Create
|
||||||
|
var createResponse = await _client.PostAsJsonAsync($"Users/{_userId}/HomeSections", newSection);
|
||||||
|
|
||||||
|
// Assert - Create
|
||||||
|
createResponse.EnsureSuccessStatusCode();
|
||||||
|
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||||
|
|
||||||
|
var createdSection = await createResponse.Content.ReadFromJsonAsync<HomeSectionDto>();
|
||||||
|
Assert.NotNull(createdSection);
|
||||||
|
Assert.NotNull(createdSection.Id);
|
||||||
|
_createdSectionIds.Add(createdSection.Id.Value);
|
||||||
|
|
||||||
|
// Act - Get
|
||||||
|
var getResponse = await _client.GetAsync($"Users/{_userId}/HomeSections/{createdSection.Id}");
|
||||||
|
|
||||||
|
// Assert - Get
|
||||||
|
getResponse.EnsureSuccessStatusCode();
|
||||||
|
var retrievedSection = await getResponse.Content.ReadFromJsonAsync<HomeSectionDto>();
|
||||||
|
|
||||||
|
Assert.NotNull(retrievedSection);
|
||||||
|
Assert.Equal(createdSection.Id, retrievedSection.Id);
|
||||||
|
Assert.Equal("Integration Test Section", retrievedSection.SectionOptions.Name);
|
||||||
|
Assert.Equal(HomeSectionType.LatestMedia, retrievedSection.SectionOptions.SectionType);
|
||||||
|
Assert.Equal(100, retrievedSection.SectionOptions.Priority);
|
||||||
|
Assert.Equal(8, retrievedSection.SectionOptions.MaxItems);
|
||||||
|
Assert.Equal(SortOrder.Descending, retrievedSection.SectionOptions.SortOrder);
|
||||||
|
Assert.Equal(SortOrder.Ascending, retrievedSection.SectionOptions.SortBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Integration test - requires running server")]
|
||||||
|
public async Task UpdateHomeSection_ReturnsNoContent()
|
||||||
|
{
|
||||||
|
// Arrange - Create a section first
|
||||||
|
var newSection = new HomeSectionDto
|
||||||
|
{
|
||||||
|
SectionOptions = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Section To Update",
|
||||||
|
SectionType = HomeSectionType.NextUp,
|
||||||
|
Priority = 50,
|
||||||
|
MaxItems = 5,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = SortOrder.Ascending
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var createResponse = await _client.PostAsJsonAsync($"Users/{_userId}/HomeSections", newSection);
|
||||||
|
createResponse.EnsureSuccessStatusCode();
|
||||||
|
var createdSection = await createResponse.Content.ReadFromJsonAsync<HomeSectionDto>();
|
||||||
|
Assert.NotNull(createdSection);
|
||||||
|
Assert.NotNull(createdSection.Id);
|
||||||
|
_createdSectionIds.Add(createdSection.Id.Value);
|
||||||
|
|
||||||
|
// Arrange - Update data
|
||||||
|
var updateSection = new HomeSectionDto
|
||||||
|
{
|
||||||
|
SectionOptions = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Updated Section Name",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 25,
|
||||||
|
MaxItems = 12,
|
||||||
|
SortOrder = SortOrder.Descending,
|
||||||
|
SortBy = SortOrder.Descending
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var updateResponse = await _client.PutAsJsonAsync($"Users/{_userId}/HomeSections/{createdSection.Id}", updateSection);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode);
|
||||||
|
|
||||||
|
// Verify the update
|
||||||
|
var getResponse = await _client.GetAsync($"Users/{_userId}/HomeSections/{createdSection.Id}");
|
||||||
|
getResponse.EnsureSuccessStatusCode();
|
||||||
|
var retrievedSection = await getResponse.Content.ReadFromJsonAsync<HomeSectionDto>();
|
||||||
|
|
||||||
|
Assert.NotNull(retrievedSection);
|
||||||
|
Assert.Equal("Updated Section Name", retrievedSection.SectionOptions.Name);
|
||||||
|
Assert.Equal(HomeSectionType.LatestMedia, retrievedSection.SectionOptions.SectionType);
|
||||||
|
Assert.Equal(25, retrievedSection.SectionOptions.Priority);
|
||||||
|
Assert.Equal(12, retrievedSection.SectionOptions.MaxItems);
|
||||||
|
Assert.Equal(SortOrder.Descending, retrievedSection.SectionOptions.SortOrder);
|
||||||
|
Assert.Equal(SortOrder.Descending, retrievedSection.SectionOptions.SortBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Integration test - requires running server")]
|
||||||
|
public async Task DeleteHomeSection_ReturnsNoContent()
|
||||||
|
{
|
||||||
|
// Arrange - Create a section first
|
||||||
|
var newSection = new HomeSectionDto
|
||||||
|
{
|
||||||
|
SectionOptions = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Section To Delete",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 75,
|
||||||
|
MaxItems = 3,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = SortOrder.Descending
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var createResponse = await _client.PostAsJsonAsync($"Users/{_userId}/HomeSections", newSection);
|
||||||
|
createResponse.EnsureSuccessStatusCode();
|
||||||
|
var createdSection = await createResponse.Content.ReadFromJsonAsync<HomeSectionDto>();
|
||||||
|
Assert.NotNull(createdSection);
|
||||||
|
Assert.NotNull(createdSection.Id);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var deleteResponse = await _client.DeleteAsync($"Users/{_userId}/HomeSections/{createdSection.Id}");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
var getResponse = await _client.GetAsync($"Users/{_userId}/HomeSections/{createdSection.Id}");
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
// Clean up any sections created during tests
|
||||||
|
foreach (var sectionId in _createdSectionIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_client.DeleteAsync($"Users/{_userId}/HomeSections/{sectionId}").Wait();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_client.Dispose();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jellyfin.Api.Models.HomeSectionDto;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Api.Tests.Models.HomeSectionDto
|
||||||
|
{
|
||||||
|
public class HomeSectionDtoTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HomeSectionDto_DefaultConstructor_InitializesProperties()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var dto = new Jellyfin.Api.Models.HomeSectionDto.HomeSectionDto();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(dto.Id);
|
||||||
|
Assert.NotNull(dto.SectionOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HomeSectionDto_WithValues_StoresCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var options = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Test Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 1,
|
||||||
|
MaxItems = 10,
|
||||||
|
SortOrder = SortOrder.Descending,
|
||||||
|
SortBy = SortOrder.Ascending
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var dto = new Jellyfin.Api.Models.HomeSectionDto.HomeSectionDto
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
SectionOptions = options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(id, dto.Id);
|
||||||
|
Assert.Same(options, dto.SectionOptions);
|
||||||
|
Assert.Equal("Test Section", dto.SectionOptions.Name);
|
||||||
|
Assert.Equal(HomeSectionType.LatestMedia, dto.SectionOptions.SectionType);
|
||||||
|
Assert.Equal(1, dto.SectionOptions.Priority);
|
||||||
|
Assert.Equal(10, dto.SectionOptions.MaxItems);
|
||||||
|
Assert.Equal(SortOrder.Descending, dto.SectionOptions.SortOrder);
|
||||||
|
Assert.Equal(SortOrder.Ascending, dto.SectionOptions.SortBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HomeSectionDto_SectionOptionsRequired_ValidationFails()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var dto = new Jellyfin.Api.Models.HomeSectionDto.HomeSectionDto
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
SectionOptions = new HomeSectionOptions() // Use empty options instead of null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set SectionOptions to null for validation test
|
||||||
|
// This is a workaround for non-nullable reference types
|
||||||
|
var validationContext = new ValidationContext(dto);
|
||||||
|
var validationResults = new List<ValidationResult>();
|
||||||
|
|
||||||
|
// Use reflection to set the SectionOptions to null for validation testing
|
||||||
|
var propertyInfo = dto.GetType().GetProperty("SectionOptions");
|
||||||
|
propertyInfo?.SetValue(dto, null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var isValid = Validator.TryValidateObject(dto, validationContext, validationResults, true);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(isValid);
|
||||||
|
Assert.Single(validationResults);
|
||||||
|
Assert.Contains("SectionOptions", validationResults[0].MemberNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HomeSectionOptions_DefaultConstructor_InitializesProperties()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var options = new HomeSectionOptions();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(string.Empty, options.Name);
|
||||||
|
Assert.Equal(HomeSectionType.None, options.SectionType);
|
||||||
|
Assert.Equal(0, options.Priority);
|
||||||
|
Assert.Equal(10, options.MaxItems);
|
||||||
|
Assert.Equal(SortOrder.Ascending, options.SortOrder);
|
||||||
|
Assert.Equal(SortOrder.Ascending, options.SortBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HomeSectionOptions_WithValues_StoresCorrectly()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var options = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Custom Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 5,
|
||||||
|
MaxItems = 20,
|
||||||
|
SortOrder = SortOrder.Descending,
|
||||||
|
SortBy = SortOrder.Descending
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("Custom Section", options.Name);
|
||||||
|
Assert.Equal(HomeSectionType.LatestMedia, options.SectionType);
|
||||||
|
Assert.Equal(5, options.Priority);
|
||||||
|
Assert.Equal(20, options.MaxItems);
|
||||||
|
Assert.Equal(SortOrder.Descending, options.SortOrder);
|
||||||
|
Assert.Equal(SortOrder.Descending, options.SortBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,320 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
|
using Jellyfin.Server.Implementations.Users;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Implementations.Tests.Users
|
||||||
|
{
|
||||||
|
public sealed class HomeSectionManagerTests : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly Mock<IDbContextFactory<JellyfinDbContext>> _mockDbContextFactory;
|
||||||
|
private readonly Mock<JellyfinDbContext> _mockDbContext;
|
||||||
|
private readonly HomeSectionManager _manager;
|
||||||
|
private readonly Guid _userId = Guid.NewGuid();
|
||||||
|
private readonly List<UserHomeSection> _homeSections;
|
||||||
|
private readonly Mock<DbSet<UserHomeSection>> _mockDbSet;
|
||||||
|
|
||||||
|
public HomeSectionManagerTests()
|
||||||
|
{
|
||||||
|
_homeSections = new List<UserHomeSection>();
|
||||||
|
|
||||||
|
// Setup mock DbSet for UserHomeSections
|
||||||
|
_mockDbSet = CreateMockDbSet(_homeSections);
|
||||||
|
|
||||||
|
// Setup mock DbContext
|
||||||
|
var mockLogger = new Mock<ILogger<JellyfinDbContext>>();
|
||||||
|
var mockProvider = new Mock<IJellyfinDatabaseProvider>();
|
||||||
|
_mockDbContext = new Mock<JellyfinDbContext>(
|
||||||
|
new DbContextOptions<JellyfinDbContext>(),
|
||||||
|
mockLogger.Object,
|
||||||
|
mockProvider.Object);
|
||||||
|
|
||||||
|
// Setup the property to return our mock DbSet
|
||||||
|
_mockDbContext.Setup(c => c.Set<UserHomeSection>()).Returns(_mockDbSet.Object);
|
||||||
|
|
||||||
|
// Setup mock DbContextFactory
|
||||||
|
_mockDbContextFactory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||||
|
_mockDbContextFactory.Setup(f => f.CreateDbContext()).Returns(_mockDbContext.Object);
|
||||||
|
|
||||||
|
_manager = new HomeSectionManager(_mockDbContextFactory.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHomeSections_ReturnsAllSectionsForUser()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId1 = Guid.NewGuid();
|
||||||
|
var sectionId2 = Guid.NewGuid();
|
||||||
|
|
||||||
|
_homeSections.AddRange(new[]
|
||||||
|
{
|
||||||
|
new UserHomeSection
|
||||||
|
{
|
||||||
|
UserId = _userId,
|
||||||
|
SectionId = sectionId1,
|
||||||
|
Name = "Test Section 1",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 1,
|
||||||
|
MaxItems = 10,
|
||||||
|
SortOrder = SortOrder.Descending,
|
||||||
|
SortBy = (int)SortOrder.Ascending
|
||||||
|
},
|
||||||
|
new UserHomeSection
|
||||||
|
{
|
||||||
|
UserId = _userId,
|
||||||
|
SectionId = sectionId2,
|
||||||
|
Name = "Test Section 2",
|
||||||
|
SectionType = HomeSectionType.NextUp,
|
||||||
|
Priority = 2,
|
||||||
|
MaxItems = 5,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = (int)SortOrder.Descending
|
||||||
|
},
|
||||||
|
new UserHomeSection
|
||||||
|
{
|
||||||
|
UserId = Guid.NewGuid(), // Different user
|
||||||
|
SectionId = Guid.NewGuid(),
|
||||||
|
Name = "Other User Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 1,
|
||||||
|
MaxItems = 15,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = (int)SortOrder.Ascending
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _manager.GetHomeSections(_userId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
Assert.Equal("Test Section 1", result[0].Name);
|
||||||
|
Assert.Equal("Test Section 2", result[1].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHomeSection_WithValidId_ReturnsSection()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
_homeSections.Add(new UserHomeSection
|
||||||
|
{
|
||||||
|
UserId = _userId,
|
||||||
|
SectionId = sectionId,
|
||||||
|
Name = "Test Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 1,
|
||||||
|
MaxItems = 10,
|
||||||
|
SortOrder = SortOrder.Descending,
|
||||||
|
SortBy = (int)SortOrder.Ascending
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _manager.GetHomeSection(_userId, sectionId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Test Section", result.Name);
|
||||||
|
Assert.Equal(HomeSectionType.LatestMedia, result.SectionType);
|
||||||
|
Assert.Equal(1, result.Priority);
|
||||||
|
Assert.Equal(10, result.MaxItems);
|
||||||
|
Assert.Equal(SortOrder.Descending, result.SortOrder);
|
||||||
|
Assert.Equal(SortOrder.Ascending, result.SortBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetHomeSection_WithInvalidId_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _manager.GetHomeSection(_userId, sectionId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateHomeSection_AddsNewSectionToDatabase()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var options = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "New Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 3,
|
||||||
|
MaxItems = 15,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = SortOrder.Ascending
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var sectionId = _manager.CreateHomeSection(_userId, options);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_mockDbSet.Verify(
|
||||||
|
m => m.Add(
|
||||||
|
It.Is<UserHomeSection>(s =>
|
||||||
|
s.UserId.Equals(_userId) &&
|
||||||
|
s.SectionId.Equals(sectionId) &&
|
||||||
|
s.Name == "New Section" &&
|
||||||
|
s.SectionType == HomeSectionType.LatestMedia &&
|
||||||
|
s.Priority == 3 &&
|
||||||
|
s.MaxItems == 15 &&
|
||||||
|
s.SortOrder == SortOrder.Ascending &&
|
||||||
|
s.SortBy == (int)SortOrder.Ascending)),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateHomeSection_WithValidId_UpdatesSection()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
_homeSections.Add(new UserHomeSection
|
||||||
|
{
|
||||||
|
UserId = _userId,
|
||||||
|
SectionId = sectionId,
|
||||||
|
Name = "Original Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 1,
|
||||||
|
MaxItems = 10,
|
||||||
|
SortOrder = SortOrder.Descending,
|
||||||
|
SortBy = (int)SortOrder.Ascending
|
||||||
|
});
|
||||||
|
|
||||||
|
var options = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Updated Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 3,
|
||||||
|
MaxItems = 15,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = SortOrder.Descending
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _manager.UpdateHomeSection(_userId, sectionId, options);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result);
|
||||||
|
var section = _homeSections.First(s => s.SectionId.Equals(sectionId));
|
||||||
|
Assert.Equal("Updated Section", section.Name);
|
||||||
|
Assert.Equal(HomeSectionType.LatestMedia, section.SectionType);
|
||||||
|
Assert.Equal(3, section.Priority);
|
||||||
|
Assert.Equal(15, section.MaxItems);
|
||||||
|
Assert.Equal(SortOrder.Ascending, section.SortOrder);
|
||||||
|
Assert.Equal((int)SortOrder.Descending, section.SortBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateHomeSection_WithInvalidId_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var options = new HomeSectionOptions
|
||||||
|
{
|
||||||
|
Name = "Updated Section",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 3,
|
||||||
|
MaxItems = 15,
|
||||||
|
SortOrder = SortOrder.Ascending,
|
||||||
|
SortBy = SortOrder.Descending
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _manager.UpdateHomeSection(_userId, sectionId, options);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteHomeSection_WithValidId_RemovesSection()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var section = new UserHomeSection
|
||||||
|
{
|
||||||
|
UserId = _userId,
|
||||||
|
SectionId = sectionId,
|
||||||
|
Name = "Section to Delete",
|
||||||
|
SectionType = HomeSectionType.LatestMedia,
|
||||||
|
Priority = 1,
|
||||||
|
MaxItems = 10,
|
||||||
|
SortOrder = SortOrder.Descending,
|
||||||
|
SortBy = (int)SortOrder.Ascending
|
||||||
|
};
|
||||||
|
|
||||||
|
_homeSections.Add(section);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _manager.DeleteHomeSection(_userId, sectionId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result);
|
||||||
|
_mockDbSet.Verify(m => m.Remove(section), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteHomeSection_WithInvalidId_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sectionId = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _manager.DeleteHomeSection(_userId, sectionId);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result);
|
||||||
|
_mockDbSet.Verify(m => m.Remove(It.IsAny<UserHomeSection>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveChanges_CallsSaveChangesOnDbContext()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
_manager.SaveChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_mockDbContext.Verify(c => c.SaveChanges(), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<DbSet<T>> CreateMockDbSet<T>(List<T> data)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
var queryable = data.AsQueryable();
|
||||||
|
var mockDbSet = new Mock<DbSet<T>>();
|
||||||
|
|
||||||
|
mockDbSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(queryable.Provider);
|
||||||
|
mockDbSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(queryable.Expression);
|
||||||
|
mockDbSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
|
||||||
|
mockDbSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(() => queryable.GetEnumerator());
|
||||||
|
|
||||||
|
mockDbSet.Setup(m => m.Add(It.IsAny<T>())).Callback<T>(data.Add);
|
||||||
|
mockDbSet.Setup(m => m.Remove(It.IsAny<T>())).Callback<T>(item => data.Remove(item));
|
||||||
|
|
||||||
|
return mockDbSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _manager.DisposeAsync().ConfigureAwait(false);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user