From 8abcaee84602ff4b34fea775d636b4df7398bd14 Mon Sep 17 00:00:00 2001 From: John Corser Date: Mon, 31 Mar 2025 12:43:56 -0400 Subject: [PATCH] wip - home sections --- .../Controllers/HomeSectionController.cs | 320 ++++++++++++++++++ .../HomeSectionDto/EnrichedHomeSectionDto.cs | 28 ++ .../Models/HomeSectionDto/HomeSectionDto.cs | 23 ++ .../Users/HomeSectionManager.cs | 180 ++++++++++ Jellyfin.Server/Startup.cs | 3 + .../IHomeSectionManager.cs | 57 ++++ .../Configuration/HomeSectionOptions.cs | 55 +++ .../Entities/UserHomeSection.cs | 88 +++++ .../JellyfinDbContext.cs | 5 + .../UserHomeSectionConfiguration.cs | 49 +++ .../20250331000000_AddUserHomeSections.cs | 53 +++ .../Controllers/HomeSectionControllerTests.cs | 264 +++++++++++++++ .../Integration/HomeSectionApiTests.cs | 202 +++++++++++ .../HomeSectionDto/HomeSectionDtoTests.cs | 123 +++++++ .../Users/HomeSectionManagerTests.cs | 320 ++++++++++++++++++ 15 files changed, 1770 insertions(+) create mode 100644 Jellyfin.Api/Controllers/HomeSectionController.cs create mode 100644 Jellyfin.Api/Models/HomeSectionDto/EnrichedHomeSectionDto.cs create mode 100644 Jellyfin.Api/Models/HomeSectionDto/HomeSectionDto.cs create mode 100644 Jellyfin.Server.Implementations/Users/HomeSectionManager.cs create mode 100644 MediaBrowser.Controller/IHomeSectionManager.cs create mode 100644 MediaBrowser.Model/Configuration/HomeSectionOptions.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserHomeSection.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserHomeSectionConfiguration.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331000000_AddUserHomeSections.cs create mode 100644 tests/Jellyfin.Api.Tests/Controllers/HomeSectionControllerTests.cs create mode 100644 tests/Jellyfin.Api.Tests/Integration/HomeSectionApiTests.cs create mode 100644 tests/Jellyfin.Api.Tests/Models/HomeSectionDto/HomeSectionDtoTests.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Users/HomeSectionManagerTests.cs diff --git a/Jellyfin.Api/Controllers/HomeSectionController.cs b/Jellyfin.Api/Controllers/HomeSectionController.cs new file mode 100644 index 0000000000..60cae79e68 --- /dev/null +++ b/Jellyfin.Api/Controllers/HomeSectionController.cs @@ -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 +{ + /// + /// Home Section controller. + /// + [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; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public HomeSectionController( + IHomeSectionManager homeSectionManager, + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService) + { + _homeSectionManager = homeSectionManager; + _userManager = userManager; + _libraryManager = libraryManager; + _dtoService = dtoService; + } + + /// + /// Get all home sections. + /// + /// User id. + /// Home sections retrieved. + /// An containing the home sections. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetHomeSections([FromRoute, Required] Guid userId) + { + var sections = _homeSectionManager.GetHomeSections(userId); + var result = new List(); + 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); + } + + /// + /// Get home section. + /// + /// User id. + /// Section id. + /// Home section retrieved. + /// Home section not found. + /// An containing the home section. + [HttpGet("{sectionId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult 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); + } + + /// + /// Create a new home section. + /// + /// User id. + /// The home section dto. + /// Home section created. + /// An containing the new home section. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + public ActionResult 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); + } + + /// + /// Update a home section. + /// + /// User id. + /// Section id. + /// The home section dto. + /// Home section updated. + /// Home section not found. + /// A . + [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(); + } + + /// + /// Delete a home section. + /// + /// User id. + /// Section id. + /// Home section deleted. + /// Home section not found. + /// A . + [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 GetItemsForSection(Guid userId, HomeSectionOptions options) + { + var user = _userManager.GetUserById(userId); + if (user == null) + { + return Array.Empty(); + } + + switch (options.SectionType) + { + case HomeSectionType.None: + return Array.Empty(); + case HomeSectionType.SmallLibraryTiles: + return GetLibraryTilesHomeSectionItems(userId, true); + case HomeSectionType.LibraryButtons: + return GetLibraryTilesHomeSectionItems(userId, false); + // TODO: Implement GetActiveRecordingsHomeSectionItems + case HomeSectionType.ActiveRecordings: + return Array.Empty(); + // TODO: Implement GetResumeItemsHomeSectionItems + case HomeSectionType.Resume: + return Array.Empty(); + // TODO: Implement GetResumeAudioHomeSectionItems + case HomeSectionType.ResumeAudio: + return Array.Empty(); + case HomeSectionType.LatestMedia: + return GetLatestMediaHomeSectionItems(userId, options.MaxItems); + // TODO: Implement GetNextUpHomeSectionItems + case HomeSectionType.NextUp: + return Array.Empty(); + // TODO: Implement GetLiveTvHomeSectionItems + case HomeSectionType.LiveTv: + return Array.Empty(); + // TODO: Implement ResumeBookHomeSectionItems + case HomeSectionType.ResumeBook: + return Array.Empty(); + // 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(); + } + } + + private IEnumerable GetLatestMediaHomeSectionItems(Guid userId, int maxItems) + { + var user = _userManager.GetUserById(userId); + if (user == null) + { + return Array.Empty(); + } + + 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(); + } + + private IEnumerable GetLibraryTilesHomeSectionItems(Guid userId, bool smallTiles = false) + { + var user = _userManager.GetUserById(userId); + if (user == null) + { + return Array.Empty(); + } + + // 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(); + } + } +} diff --git a/Jellyfin.Api/Models/HomeSectionDto/EnrichedHomeSectionDto.cs b/Jellyfin.Api/Models/HomeSectionDto/EnrichedHomeSectionDto.cs new file mode 100644 index 0000000000..72edd08396 --- /dev/null +++ b/Jellyfin.Api/Models/HomeSectionDto/EnrichedHomeSectionDto.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.Api.Models.HomeSectionDto +{ + /// + /// Home section dto with items. + /// + public class EnrichedHomeSectionDto + { + /// + /// Gets or sets the id. + /// + public Guid? Id { get; set; } + + /// + /// Gets or sets the section options. + /// + public HomeSectionOptions SectionOptions { get; set; } = null!; + + /// + /// Gets or sets the items. + /// + public IEnumerable Items { get; set; } = Array.Empty(); + } +} diff --git a/Jellyfin.Api/Models/HomeSectionDto/HomeSectionDto.cs b/Jellyfin.Api/Models/HomeSectionDto/HomeSectionDto.cs new file mode 100644 index 0000000000..b09c5a4a34 --- /dev/null +++ b/Jellyfin.Api/Models/HomeSectionDto/HomeSectionDto.cs @@ -0,0 +1,23 @@ +using System; +using System.ComponentModel.DataAnnotations; +using MediaBrowser.Model.Configuration; + +namespace Jellyfin.Api.Models.HomeSectionDto +{ + /// + /// Home section DTO. + /// + public class HomeSectionDto + { + /// + /// Gets or sets the id. + /// + public Guid? Id { get; set; } + + /// + /// Gets or sets the section options. + /// + [Required] + public HomeSectionOptions SectionOptions { get; set; } = new HomeSectionOptions(); + } +} diff --git a/Jellyfin.Server.Implementations/Users/HomeSectionManager.cs b/Jellyfin.Server.Implementations/Users/HomeSectionManager.cs new file mode 100644 index 0000000000..ca6d3cea32 --- /dev/null +++ b/Jellyfin.Server.Implementations/Users/HomeSectionManager.cs @@ -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 +{ + /// + /// Manages the storage and retrieval of home sections through Entity Framework. + /// + public sealed class HomeSectionManager : IHomeSectionManager, IAsyncDisposable + { + private readonly JellyfinDbContext _dbContext; + + /// + /// Initializes a new instance of the class. + /// + /// The database context factory. + public HomeSectionManager(IDbContextFactory 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(); + } + + /// + /// Ensures that the UserHomeSections table exists in the database. + /// + 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'); + "); + } + } + + /// + public IList 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(); + } + + /// + 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 + }; + } + + /// + 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; + } + + /// + 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; + } + + /// + 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; + } + + /// + public void SaveChanges() + { + _dbContext.SaveChanges(); + } + + /// + public async ValueTask DisposeAsync() + { + await _dbContext.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 688b169359..4cbc42b3a0 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -106,6 +106,9 @@ namespace Jellyfin.Server }) .ConfigurePrimaryHttpMessageHandler(eyeballsHttpClientHandlerDelegate); + // Register the HomeSectionManager + services.AddScoped(); + services.AddHttpClient(NamedClient.MusicBrainz, c => { c.DefaultRequestHeaders.UserAgent.Add(productHeader); diff --git a/MediaBrowser.Controller/IHomeSectionManager.cs b/MediaBrowser.Controller/IHomeSectionManager.cs new file mode 100644 index 0000000000..4b216da9ce --- /dev/null +++ b/MediaBrowser.Controller/IHomeSectionManager.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller +{ + /// + /// Interface for managing home sections. + /// + public interface IHomeSectionManager + { + /// + /// Gets all home sections for a user. + /// + /// The user id. + /// A list of home section options. + IList GetHomeSections(Guid userId); + + /// + /// Gets a specific home section for a user. + /// + /// The user id. + /// The section id. + /// The home section options, or null if not found. + HomeSectionOptions? GetHomeSection(Guid userId, Guid sectionId); + + /// + /// Creates a new home section for a user. + /// + /// The user id. + /// The home section options. + /// The id of the newly created section. + Guid CreateHomeSection(Guid userId, HomeSectionOptions options); + + /// + /// Updates a home section for a user. + /// + /// The user id. + /// The section id. + /// The updated home section options. + /// True if the section was updated, false if it was not found. + bool UpdateHomeSection(Guid userId, Guid sectionId, HomeSectionOptions options); + + /// + /// Deletes a home section for a user. + /// + /// The user id. + /// The section id. + /// True if the section was deleted, false if it was not found. + bool DeleteHomeSection(Guid userId, Guid sectionId); + + /// + /// Saves changes to the database. + /// + void SaveChanges(); + } +} diff --git a/MediaBrowser.Model/Configuration/HomeSectionOptions.cs b/MediaBrowser.Model/Configuration/HomeSectionOptions.cs new file mode 100644 index 0000000000..eaa7ab8e8f --- /dev/null +++ b/MediaBrowser.Model/Configuration/HomeSectionOptions.cs @@ -0,0 +1,55 @@ +#pragma warning disable CS1591 + +using System.ComponentModel; +using Jellyfin.Database.Implementations.Enums; + +namespace MediaBrowser.Model.Configuration +{ + /// + /// Options for a specific home section. + /// + public class HomeSectionOptions + { + /// + /// Initializes a new instance of the class. + /// + public HomeSectionOptions() + { + Name = string.Empty; + } + + /// + /// Gets or sets the name of the section. + /// + public string Name { get; set; } + + /// + /// Gets or sets the type of the section. + /// + public HomeSectionType SectionType { get; set; } + + /// + /// Gets or sets the priority/order of this section (lower numbers appear first). + /// + [DefaultValue(0)] + public int Priority { get; set; } = 0; + + /// + /// Gets or sets the maximum number of items to display in the section. + /// + [DefaultValue(10)] + public int MaxItems { get; set; } = 10; + + /// + /// Gets or sets the sort order for items in this section. + /// + [DefaultValue(SortOrder.Ascending)] + public SortOrder SortOrder { get; set; } = SortOrder.Ascending; + + /// + /// Gets or sets how items should be sorted in this section. + /// + [DefaultValue(SortOrder.Ascending)] + public SortOrder SortBy { get; set; } = SortOrder.Ascending; + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserHomeSection.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserHomeSection.cs new file mode 100644 index 0000000000..1e529eea6e --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserHomeSection.cs @@ -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 +{ + /// + /// An entity representing a user's home section. + /// + public class UserHomeSection + { + /// + /// Gets the Id. + /// + /// + /// Identity. Required. + /// + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; private set; } + + /// + /// Gets or sets the user Id. + /// + /// + /// Required. + /// + public Guid UserId { get; set; } + + /// + /// Gets or sets the section Id. + /// + /// + /// Required. + /// + public Guid SectionId { get; set; } + + /// + /// Gets or sets the name of the section. + /// + /// + /// Required. Max Length = 64. + /// + [MaxLength(64)] + [StringLength(64)] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the type of the section. + /// + /// + /// Required. + /// + public HomeSectionType SectionType { get; set; } + + /// + /// Gets or sets the priority/order of this section. + /// + /// + /// Required. + /// + public int Priority { get; set; } + + /// + /// Gets or sets the maximum number of items to display in the section. + /// + /// + /// Required. + /// + public int MaxItems { get; set; } + + /// + /// Gets or sets the sort order for items in this section. + /// + /// + /// Required. + /// + public SortOrder SortOrder { get; set; } + + /// + /// Gets or sets how items should be sorted in this section. + /// + /// + /// Required. + /// + public int SortBy { get; set; } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs index 9db70263d2..1bfd5f3ea6 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs @@ -47,6 +47,11 @@ public class JellyfinDbContext(DbContextOptions options, ILog /// public DbSet DisplayPreferences => Set(); + /// + /// Gets the containing the user home sections. + /// + public DbSet UserHomeSections => Set(); + /// /// Gets the containing the image infos. /// diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserHomeSectionConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserHomeSectionConfiguration.cs new file mode 100644 index 0000000000..eb20ed7274 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserHomeSectionConfiguration.cs @@ -0,0 +1,49 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration +{ + /// + /// Configuration for the UserHomeSection entity. + /// + public class UserHomeSectionConfiguration : IEntityTypeConfiguration + { + /// + public void Configure(EntityTypeBuilder 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(); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331000000_AddUserHomeSections.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331000000_AddUserHomeSections.cs new file mode 100644 index 0000000000..6cd83beab0 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250331000000_AddUserHomeSections.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// + /// Migration to add UserHomeSections table. + /// + public partial class AddUserHomeSections : Migration + { + /// + /// Builds the operations that will migrate the database 'up'. + /// + /// The migration builder. + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserHomeSections", + columns: table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(nullable: false), + SectionId = table.Column(nullable: false), + Name = table.Column(nullable: false), + SectionType = table.Column(nullable: false), + Priority = table.Column(nullable: false), + MaxItems = table.Column(nullable: false), + SortOrder = table.Column(nullable: false), + SortBy = table.Column(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); + } + + /// + /// Builds the operations that will migrate the database 'down'. + /// + /// The migration builder. + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserHomeSections"); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Controllers/HomeSectionControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/HomeSectionControllerTests.cs new file mode 100644 index 0000000000..89c3e56186 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/HomeSectionControllerTests.cs @@ -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 _mockHomeSectionManager; + private readonly Mock _mockUserManager; + private readonly Mock _mockLibraryManager; + private readonly Mock _mockDtoService; + private readonly HomeSectionController _controller; + private readonly Guid _userId = Guid.NewGuid(); + + public HomeSectionControllerTests() + { + _mockHomeSectionManager = new Mock(); + _mockUserManager = new Mock(); + _mockLibraryManager = new Mock(); + _mockDtoService = new Mock(); + + _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(); + _mockUserManager.Setup(m => m.GetUserById(_userId)) + .Returns(mockUser.Object); + } + + [Fact] + public void GetHomeSections_ReturnsOkResult_WithListOfSections() + { + // Arrange + var sections = new List + { + 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(result.Result); + var returnValue = Assert.IsType>(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(result.Result); + var returnValue = Assert.IsType(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(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(result.Result); + var returnValue = Assert.IsType(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(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(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(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(result); + _mockHomeSectionManager.Verify(m => m.SaveChanges(), Times.Never); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Integration/HomeSectionApiTests.cs b/tests/Jellyfin.Api.Tests/Integration/HomeSectionApiTests.cs new file mode 100644 index 0000000000..c3333a61a0 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Integration/HomeSectionApiTests.cs @@ -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 +{ + /// + /// Integration tests for the Home Section API. + /// These tests require a running Jellyfin server and should be run in a controlled environment. + /// + 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 _createdSectionIds = new List(); + + 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>(); + 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(); + 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(); + + 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(); + 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(); + + 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(); + 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); + } + } +} diff --git a/tests/Jellyfin.Api.Tests/Models/HomeSectionDto/HomeSectionDtoTests.cs b/tests/Jellyfin.Api.Tests/Models/HomeSectionDto/HomeSectionDtoTests.cs new file mode 100644 index 0000000000..1556462cc5 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Models/HomeSectionDto/HomeSectionDtoTests.cs @@ -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(); + + // 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); + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/HomeSectionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/HomeSectionManagerTests.cs new file mode 100644 index 0000000000..72fa2d5b46 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/HomeSectionManagerTests.cs @@ -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> _mockDbContextFactory; + private readonly Mock _mockDbContext; + private readonly HomeSectionManager _manager; + private readonly Guid _userId = Guid.NewGuid(); + private readonly List _homeSections; + private readonly Mock> _mockDbSet; + + public HomeSectionManagerTests() + { + _homeSections = new List(); + + // Setup mock DbSet for UserHomeSections + _mockDbSet = CreateMockDbSet(_homeSections); + + // Setup mock DbContext + var mockLogger = new Mock>(); + var mockProvider = new Mock(); + _mockDbContext = new Mock( + new DbContextOptions(), + mockLogger.Object, + mockProvider.Object); + + // Setup the property to return our mock DbSet + _mockDbContext.Setup(c => c.Set()).Returns(_mockDbSet.Object); + + // Setup mock DbContextFactory + _mockDbContextFactory = new Mock>(); + _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(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()), Times.Never); + } + + [Fact] + public void SaveChanges_CallsSaveChangesOnDbContext() + { + // Act + _manager.SaveChanges(); + + // Assert + _mockDbContext.Verify(c => c.SaveChanges(), Times.Once); + } + + private static Mock> CreateMockDbSet(List data) + where T : class + { + var queryable = data.AsQueryable(); + var mockDbSet = new Mock>(); + + mockDbSet.As>().Setup(m => m.Provider).Returns(queryable.Provider); + mockDbSet.As>().Setup(m => m.Expression).Returns(queryable.Expression); + mockDbSet.As>().Setup(m => m.ElementType).Returns(queryable.ElementType); + mockDbSet.As>().Setup(m => m.GetEnumerator()).Returns(() => queryable.GetEnumerator()); + + mockDbSet.Setup(m => m.Add(It.IsAny())).Callback(data.Add); + mockDbSet.Setup(m => m.Remove(It.IsAny())).Callback(item => data.Remove(item)); + + return mockDbSet; + } + + public async ValueTask DisposeAsync() + { + await _manager.DisposeAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } + } +}