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
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>
169 lines
7.3 KiB
C#
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);
|
|
}
|
|
}
|