Backend slice for the ChicoryTV playouts and collections screens.
PlayoutController:
- POST /api/playouts/{id}/erase-items (204; 404 pre-check; 422 unless
Block/Sequential/Scripted) -> ErasePlayoutItems
- POST /api/playouts/{id}/erase-items-and-history (204; 404; 422 unless
Classic/Block/Sequential/Scripted) -> ErasePlayoutHistory
- GET /api/playouts/items/{id}/scheduling-context (200/404) decodes a
playout item's stored context by row id via a new
GetPlayoutItemSchedulingContext query that reuses ProcessSchedulingContext
- PlayoutItemResponseModel gains HasSchedulingContext (no raw JSON in list)
- PlayoutListItemResponseModel gains PlayoutMode (ChannelNumber already present)
CollectionController:
- PUT /api/collections/{id}/custom-order (204; 404 pre-check; 422) with
UpdateCollectionCustomOrderRequest deriving CustomIndex from array order
- GetCollectionItemsHandler orders by CustomIndex (nulls last) then title/id
when the collection's UseCustomPlaybackOrder is set
Tests: controller route + behavior tests, OpenAPI ProblemDetails TestCases,
GetCollectionItems custom-order handler test. Regenerated v1.json, v1.d.ts,
endpoint-index.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
83 lines
3.6 KiB
C#
83 lines
3.6 KiB
C#
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<TvContext> dbContextFactory)
|
|
: IRequestHandler<GetCollectionItems, Either<BaseError, PagedLibraryBrowseItemsResponseModel>>
|
|
{
|
|
public async Task<Either<BaseError, PagedLibraryBrowseItemsResponseModel>> 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<int> mediaItemIds = collectionItems.Select(ci => ci.MediaItemId).ToList();
|
|
|
|
List<LibraryBrowseItemResponseModel> all =
|
|
await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken);
|
|
|
|
List<LibraryBrowseItemResponseModel> 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<LibraryBrowseItemResponseModel> page = ordered
|
|
.Skip(pageNum * pageSize)
|
|
.Take(pageSize)
|
|
.ToList();
|
|
|
|
return new PagedLibraryBrowseItemsResponseModel(ordered.Count, page);
|
|
}
|
|
}
|