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 dbContextFactory) : IRequestHandler { // 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 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 ShowMembers( TvContext dbContext, SearchResult searchResults, GetAutoTuneChannelMembers request, CancellationToken cancellationToken) { List 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 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 showIds = matchCountByShow.Keys.ToList(); // Order the distinct shows by title, then page (the show set is bounded — dozens, not thousands). List 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 pageIds = orderedShowIds .Skip(request.PageNum * request.PageSize) .Take(request.PageSize) .ToList(); List hydrated = await LibraryBrowseItemMapper.GetShows(dbContext, pageIds, cancellationToken); Dictionary 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 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 MovieMembers( TvContext dbContext, SearchResult searchResults, GetAutoTuneChannelMembers request, CancellationToken cancellationToken) { List movieIds = searchResults.Items .Where(i => i.Type == LuceneSearchIndex.MovieType) .Select(i => i.Id) .Distinct() .ToList(); if (movieIds.Count == 0) { return new PagedLibraryBrowseItemsResponseModel(0, []); } List 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 pageIds = orderedMovieIds .Skip(request.PageNum * request.PageSize) .Take(request.PageSize) .ToList(); List hydrated = await LibraryBrowseItemMapper.GetMovies(dbContext, pageIds, cancellationToken); Dictionary byId = hydrated.ToDictionary(m => m.Id); List ordered = pageIds .Where(byId.ContainsKey) .Select(id => byId[id]) .ToList(); return new PagedLibraryBrowseItemsResponseModel(total, ordered); } }