using ErsatzTV.Application.LibraryBrowse; using ErsatzTV.Core; using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Errors; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.MediaCollections; public class GetCollectionItemsHandler(IDbContextFactory dbContextFactory) : IRequestHandler> { public async Task> Handle( GetCollectionItems request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); // A null flag here means the collection row does not exist (the projection yields no row), // which lets a single query serve both the existence check and the custom-order flag. bool? useCustomPlaybackOrder = await dbContext.Collections .AsNoTracking() .Where(c => c.Id == request.Id) .Select(c => (bool?)c.UseCustomPlaybackOrder) .SingleOrDefaultAsync(cancellationToken); if (useCustomPlaybackOrder is null) { return new NotFoundError($"Collection {request.Id} does not exist."); } // The collection graph is bounded, so load every member (with its CustomIndex) and hydrate // them in one shared pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media // kinds are supported because MediaItem ids are globally unique across kinds. var collectionItems = await dbContext.CollectionItems .AsNoTracking() .Where(ci => ci.CollectionId == request.Id) .Select(ci => new { ci.MediaItemId, ci.CustomIndex }) .ToListAsync(cancellationToken); List mediaItemIds = collectionItems.Select(ci => ci.MediaItemId).ToList(); List all = await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken); List ordered; if (useCustomPlaybackOrder.Value) { // Custom order: sort by CustomIndex (items without one sort last), then title/id as a // stable tiebreak. var customIndexByMediaItemId = collectionItems .GroupBy(ci => ci.MediaItemId) .ToDictionary(g => g.Key, g => g.Select(ci => ci.CustomIndex).FirstOrDefault()); ordered = all .OrderBy(i => customIndexByMediaItemId.TryGetValue(i.Id, out int? customIndex) && customIndex.HasValue ? customIndex.Value : int.MaxValue) .ThenBy(i => i.Title, StringComparer.OrdinalIgnoreCase) .ThenBy(i => i.Id) .ToList(); } else { // Stable title ordering mirrors the library-browse handler (which orders its rows by name), // giving the SPA a deterministic, browsable list independent of collection insertion order. ordered = all .OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase) .ThenBy(i => i.Id) .ToList(); } int pageNum = Math.Max(0, request.PageNum); int pageSize = Math.Clamp(request.PageSize, 1, 100); List page = ordered .Skip(pageNum * pageSize) .Take(pageSize) .ToList(); return new PagedLibraryBrowseItemsResponseModel(ordered.Count, page); } }