Files
ersatztv/ErsatzTV.Application/Channels/GetAutoTuneChannelMembersHandler.cs
T
timothyandClaude Opus 4.8 1be45fbe88
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 11s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 6m6s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 7m8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 10m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m20s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(384): guard out-of-range axis (L1 review) + tidy page clamp
Cold adversarial review found a crafted numeric `?axis=5` binds past
[ApiController]'s auto-400 (ModelState valid), then AutoTuneAxisMap.GenerateQuery's
`_ => throw` surfaces as a 500 (no global exception filter). Short-circuit an
undefined axis to an empty result in the handler — matching #69's EnumerateAxis
`_ => []` graceful-empty pattern. Adds a regression test asserting no search runs.

Also simplifies the redundant pageSize lower clamp (review N4): the `<= 0 ? 100`
guard already floors it, so `Math.Clamp(_, 1, 200)` -> `Math.Min(_, 200)`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:40:44 +02:00

169 lines
7.3 KiB
C#

using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
// Runs the server-owned SmartCollection query for an axis value through the same search index the
// built channel's playout uses, then rolls the matching leaf items up to their distinct content
// sources: parent shows for the episode axes, movies for the movie-genre axis. Feeds the Auto-Tune
// DetailPanel's read-only-by-default source list (#383/#384).
public class GetAutoTuneChannelMembersHandler(
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAutoTuneChannelMembers, PagedLibraryBrowseItemsResponseModel>
{
// Mirrors MediaCollectionRepository.GetSmartCollectionItems: the index dislikes a zero limit, so
// pull up to 10k matching leaf items and group in memory. A source whose matches fall entirely
// beyond this cap would be under-counted (the same staleness bound the smart-collection path
// already accepts) — realistic axis values resolve to far fewer than 10k items.
private const int SearchLimit = 10_000;
public async Task<PagedLibraryBrowseItemsResponseModel> Handle(
GetAutoTuneChannelMembers request,
CancellationToken cancellationToken)
{
// An out-of-range numeric axis binds successfully (ModelState stays valid, so [ApiController]'s
// auto-400 does not fire); treat it as no results rather than letting GenerateQuery's
// ArgumentOutOfRangeException surface as a 500 — matching #69's EnumerateAxis `_ => []`.
if (string.IsNullOrWhiteSpace(request.Value) || !Enum.IsDefined(request.Axis))
{
return new PagedLibraryBrowseItemsResponseModel(0, []);
}
string query = AutoTuneAxisMap.GenerateQuery(request.Axis, request.Value);
SearchResult searchResults = await searchIndex.Search(
query,
string.Empty,
0,
SearchLimit,
cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return request.Axis switch
{
AutoTuneAxis.MovieGenre => await MovieMembers(dbContext, searchResults, request, cancellationToken),
_ => await ShowMembers(dbContext, searchResults, request, cancellationToken)
};
}
// Episode axes (TvShow / TvGenre): roll matching episodes up to their distinct parent shows.
private static async Task<PagedLibraryBrowseItemsResponseModel> ShowMembers(
TvContext dbContext,
SearchResult searchResults,
GetAutoTuneChannelMembers request,
CancellationToken cancellationToken)
{
List<int> episodeIds = searchResults.Items
.Where(i => i.Type == LuceneSearchIndex.EpisodeType)
.Select(i => i.Id)
.ToList();
if (episodeIds.Count == 0)
{
return new PagedLibraryBrowseItemsResponseModel(0, []);
}
// Per-show count is the number of episodes THIS channel's query contributes, not the show's
// total episode count (Episode -> Season -> ShowId; proven query style from LibraryBrowseItemMapper).
Dictionary<int, int> matchCountByShow = (await dbContext.Episodes
.AsNoTracking()
.Where(e => episodeIds.Contains(e.Id))
.Select(e => new { e.Id, e.Season.ShowId })
.ToListAsync(cancellationToken))
.GroupBy(x => x.ShowId)
.ToDictionary(g => g.Key, g => g.Count());
List<int> showIds = matchCountByShow.Keys.ToList();
// Order the distinct shows by title, then page (the show set is bounded — dozens, not thousands).
List<int> orderedShowIds = (await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => showIds.Contains(sm.ShowId))
.Select(sm => new { sm.ShowId, sm.Title })
.ToListAsync(cancellationToken))
.GroupBy(x => x.ShowId)
.Select(g => new { ShowId = g.Key, Title = g.OrderBy(x => x.Title).Select(x => x.Title).FirstOrDefault() })
.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.ShowId)
.Select(x => x.ShowId)
.ToList();
int total = orderedShowIds.Count;
List<int> pageIds = orderedShowIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> hydrated =
await LibraryBrowseItemMapper.GetShows(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = hydrated.ToDictionary(s => s.Id);
// GetShows groups by show id, so restore the requested title order and override its total-episode
// ItemCount with the query-matching count.
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id] with
{
ItemCount = matchCountByShow.TryGetValue(id, out int count) ? count : byId[id].ItemCount
})
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
// Movie-genre axis: the matching movies are themselves the distinct content sources.
private static async Task<PagedLibraryBrowseItemsResponseModel> MovieMembers(
TvContext dbContext,
SearchResult searchResults,
GetAutoTuneChannelMembers request,
CancellationToken cancellationToken)
{
List<int> movieIds = searchResults.Items
.Where(i => i.Type == LuceneSearchIndex.MovieType)
.Select(i => i.Id)
.Distinct()
.ToList();
if (movieIds.Count == 0)
{
return new PagedLibraryBrowseItemsResponseModel(0, []);
}
List<int> orderedMovieIds = (await dbContext.MovieMetadata
.AsNoTracking()
.Where(mm => movieIds.Contains(mm.MovieId))
.Select(mm => new { mm.MovieId, mm.Title })
.ToListAsync(cancellationToken))
.GroupBy(x => x.MovieId)
.Select(g => new { MovieId = g.Key, Title = g.OrderBy(x => x.Title).Select(x => x.Title).FirstOrDefault() })
.OrderBy(x => x.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.MovieId)
.Select(x => x.MovieId)
.ToList();
int total = orderedMovieIds.Count;
List<int> pageIds = orderedMovieIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> hydrated =
await LibraryBrowseItemMapper.GetMovies(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = hydrated.ToDictionary(m => m.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
}