diff --git a/ErsatzTV.Application/Channels/GetAutoTuneChannelMembers.cs b/ErsatzTV.Application/Channels/GetAutoTuneChannelMembers.cs new file mode 100644 index 000000000..cd8c291ab --- /dev/null +++ b/ErsatzTV.Application/Channels/GetAutoTuneChannelMembers.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Core.Api.LibraryBrowse; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Channels; + +// Read-only enumeration of the distinct content-source members a proposed auto-tune channel's +// server-generated SmartCollection query resolves to (issue #384). The client passes axis+value; the +// server owns query generation (AutoTuneAxisMap.GenerateQuery) — the client never sends Lucene. +public record GetAutoTuneChannelMembers( + AutoTuneAxis Axis, + string Value, + int PageNum, + int PageSize) : IRequest; diff --git a/ErsatzTV.Application/Channels/GetAutoTuneChannelMembersHandler.cs b/ErsatzTV.Application/Channels/GetAutoTuneChannelMembersHandler.cs new file mode 100644 index 000000000..b28ad9efe --- /dev/null +++ b/ErsatzTV.Application/Channels/GetAutoTuneChannelMembersHandler.cs @@ -0,0 +1,168 @@ +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); + } +} diff --git a/ErsatzTV.Tests/Application/Channels/GetAutoTuneChannelMembersHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/GetAutoTuneChannelMembersHandlerTests.cs new file mode 100644 index 000000000..f193aa875 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/GetAutoTuneChannelMembersHandlerTests.cs @@ -0,0 +1,334 @@ +using ErsatzTV.Application.Channels; +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 ErsatzTV.Tests.Support; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Channels; + +[TestFixture] +public class GetAutoTuneChannelMembersHandlerTests +{ + private InMemoryTvContext _db = null!; + private ISearchIndex _searchIndex = null!; + + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _searchIndex = Substitute.For(); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + private void ReturnsSearch(params SearchItem[] items) => + _searchIndex.Search( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new SearchResult(items.ToList(), items.Length)); + + [Test] + public async Task Handle_Should_Generate_The_Server_Owned_Query_For_The_Axis_Value() + { + ReturnsSearch(); + var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory); + + await handler.Handle( + new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 0, 100), + CancellationToken.None); + + // The client never sends Lucene; the handler builds it from AutoTuneAxisMap.GenerateQuery. + await _searchIndex.Received(1).Search( + Arg.Is(q => q == "type:episode AND genre:\"Comedy\""), + string.Empty, + 0, + 10_000, + Arg.Any()); + } + + [Test] + public async Task Handle_Should_Not_Search_For_A_Blank_Value() + { + var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory); + + PagedLibraryBrowseItemsResponseModel result = await handler.Handle( + new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, " ", 0, 100), + CancellationToken.None); + + result.TotalCount.ShouldBe(0); + result.Page.ShouldBeEmpty(); + await _searchIndex.DidNotReceive().Search( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Test] + public async Task Handle_Should_Roll_Episodes_Up_To_Distinct_Parent_Shows_With_Matching_Counts() + { + await SeedTwoShowGenreGraph(); + // Show 10 contributes episodes 101 & 102; show 20 contributes episode 201. Episode 103 (show 10) + // exists but does NOT match the query, so it must not inflate show 10's count. + ReturnsSearch( + new SearchItem(LuceneSearchIndex.EpisodeType, 101), + new SearchItem(LuceneSearchIndex.EpisodeType, 102), + new SearchItem(LuceneSearchIndex.EpisodeType, 201)); + var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory); + + PagedLibraryBrowseItemsResponseModel result = await handler.Handle( + new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 0, 100), + CancellationToken.None); + + result.TotalCount.ShouldBe(2); + // Ordered by title: "Alpha Show" (10) before "Beta Show" (20). + result.Page.Count.ShouldBe(2); + result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.TelevisionShow); + result.Page[0].Title.ShouldBe("Alpha Show"); + result.Page[0].Id.ShouldBe(10); + result.Page[0].ItemCount.ShouldBe(2); // matching episodes only, not the show's 3 total + result.Page[1].Title.ShouldBe("Beta Show"); + result.Page[1].Id.ShouldBe(20); + result.Page[1].ItemCount.ShouldBe(1); + } + + [Test] + public async Task Handle_Should_Page_Distinct_Shows_By_Title() + { + await SeedTwoShowGenreGraph(); + ReturnsSearch( + new SearchItem(LuceneSearchIndex.EpisodeType, 101), + new SearchItem(LuceneSearchIndex.EpisodeType, 201)); + var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory); + + PagedLibraryBrowseItemsResponseModel page0 = await handler.Handle( + new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 0, 1), + CancellationToken.None); + PagedLibraryBrowseItemsResponseModel page1 = await handler.Handle( + new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Comedy", 1, 1), + CancellationToken.None); + + page0.TotalCount.ShouldBe(2); + page0.Page.Count.ShouldBe(1); + page0.Page[0].Title.ShouldBe("Alpha Show"); + page1.TotalCount.ShouldBe(2); + page1.Page.Count.ShouldBe(1); + page1.Page[0].Title.ShouldBe("Beta Show"); + } + + [Test] + public async Task Handle_Should_Return_Movies_As_Members_For_The_Movie_Genre_Axis() + { + await SeedMovieGenreGraph(); + ReturnsSearch( + new SearchItem(LuceneSearchIndex.MovieType, 30), + new SearchItem(LuceneSearchIndex.MovieType, 31)); + var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory); + + PagedLibraryBrowseItemsResponseModel result = await handler.Handle( + new GetAutoTuneChannelMembers(AutoTuneAxis.MovieGenre, "Comedy", 0, 100), + CancellationToken.None); + + result.TotalCount.ShouldBe(2); + // Ordered by title: "Aardvark" before "Zebra". + result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Movie); + result.Page[0].Title.ShouldBe("Aardvark"); + result.Page[0].Id.ShouldBe(31); + result.Page[0].ItemCount.ShouldBe(1); + result.Page[1].Title.ShouldBe("Zebra"); + result.Page[1].Id.ShouldBe(30); + } + + [Test] + public async Task Handle_Should_Return_Empty_When_The_Query_Matches_Nothing() + { + await SeedTwoShowGenreGraph(); + ReturnsSearch(); + var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory); + + PagedLibraryBrowseItemsResponseModel result = await handler.Handle( + new GetAutoTuneChannelMembers(AutoTuneAxis.TvGenre, "Nonexistent", 0, 100), + CancellationToken.None); + + result.TotalCount.ShouldBe(0); + result.Page.ShouldBeEmpty(); + } + + [Test] + public async Task Handle_Should_Return_Empty_For_An_Out_Of_Range_Axis_Without_Searching() + { + // A crafted numeric axis (?axis=5) binds successfully; the handler must not let + // GenerateQuery throw (which would surface as a 500) — it returns empty like a blank value. + var handler = new GetAutoTuneChannelMembersHandler(_searchIndex, _db.Factory); + + PagedLibraryBrowseItemsResponseModel result = await handler.Handle( + new GetAutoTuneChannelMembers((AutoTuneAxis)999, "Comedy", 0, 100), + CancellationToken.None); + + result.TotalCount.ShouldBe(0); + result.Page.ShouldBeEmpty(); + await _searchIndex.DidNotReceive().Search( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + private async Task SeedTwoShowGenreGraph() + { + await using TvContext context = _db.CreateContext(); + (LocalLibrary library, LibraryPath path) = MakeLibrary(1, "TV"); + + var alpha = MakeShow(10, path, "Alpha Show"); + Season alphaSeason = MakeSeason(11, path, alpha); + alphaSeason.Episodes.AddRange([ + MakeEpisode(101, path, alphaSeason), + MakeEpisode(102, path, alphaSeason), + MakeEpisode(103, path, alphaSeason) + ]); + alpha.Seasons.Add(alphaSeason); + + var beta = MakeShow(20, path, "Beta Show"); + Season betaSeason = MakeSeason(21, path, beta); + betaSeason.Episodes.Add(MakeEpisode(201, path, betaSeason)); + beta.Seasons.Add(betaSeason); + + path.MediaItems.AddRange([ + alpha, beta, alphaSeason, betaSeason, + .. alphaSeason.Episodes, .. betaSeason.Episodes + ]); + + context.LocalLibraries.Add(library); + context.Shows.AddRange(alpha, beta); + context.Seasons.AddRange(alphaSeason, betaSeason); + context.Episodes.AddRange([.. alphaSeason.Episodes, .. betaSeason.Episodes]); + await context.SaveChangesAsync(); + } + + private async Task SeedMovieGenreGraph() + { + await using TvContext context = _db.CreateContext(); + (LocalLibrary library, LibraryPath path) = MakeLibrary(2, "Movies"); + var zebra = MakeMovie(30, path, "Zebra"); + var aardvark = MakeMovie(31, path, "Aardvark"); + path.MediaItems.AddRange([zebra, aardvark]); + + context.LocalLibraries.Add(library); + context.Movies.AddRange(zebra, aardvark); + await context.SaveChangesAsync(); + } + + private static (LocalLibrary Library, LibraryPath Path) MakeLibrary(int id, string name) + { + var library = new LocalLibrary + { + Id = id, + Name = name, + MediaKind = LibraryMediaKind.Movies, + Paths = [] + }; + var path = new LibraryPath + { + Id = id, + Path = $"/media/{id}", + Library = library, + LibraryFolders = [], + MediaItems = [] + }; + library.Paths.Add(path); + return (library, path); + } + + private static Show MakeShow(int id, LibraryPath path, string title) => + new() + { + Id = id, + LibraryPath = path, + Collections = [], + CollectionItems = [], + TraktListItems = [], + Seasons = [], + ShowMetadata = + [ + new ShowMetadata + { + Title = title, + SortTitle = title, + Artwork = [], + Genres = [], + Tags = [], + Studios = [], + Actors = [], + Guids = [], + Subtitles = [] + } + ] + }; + + private static Season MakeSeason(int id, LibraryPath path, Show show) => + new() + { + Id = id, + LibraryPath = path, + Show = show, + SeasonNumber = 1, + Collections = [], + CollectionItems = [], + TraktListItems = [], + Episodes = [], + SeasonMetadata = [] + }; + + private static Episode MakeEpisode(int id, LibraryPath path, Season season) => + new() + { + Id = id, + LibraryPath = path, + Season = season, + Collections = [], + CollectionItems = [], + TraktListItems = [], + EpisodeMetadata = [], + MediaVersions = [] + }; + + private static Movie MakeMovie(int id, LibraryPath path, string title) => + new() + { + Id = id, + LibraryPath = path, + Collections = [], + CollectionItems = [], + TraktListItems = [], + MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(90) }], + MovieMetadata = + [ + new MovieMetadata + { + Title = title, + SortTitle = title, + Artwork = [], + Genres = [], + Tags = [], + Studios = [], + Actors = [], + Guids = [], + Subtitles = [], + Directors = [], + Writers = [] + } + ] + }; +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs index b6e28bb38..d313df95e 100644 --- a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -471,6 +471,49 @@ public class ChannelControllerTests await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } + [Test] + public void GetAutoTuneChannelMembers_Route_Is_Literal_And_Precedes_Id() + { + MethodInfo members = typeof(ChannelController).GetMethod(nameof(ChannelController.GetAutoTuneChannelMembers))!; + members.GetCustomAttributes(inherit: true).Single().Template + .ShouldBe("/api/v1/channels/auto-tune/members"); + } + + [Test] + public async Task GetAutoTuneChannelMembers_Should_Map_Query_And_Return_Model() + { + var model = new PagedLibraryBrowseItemsResponseModel(1, []); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(model); + + PagedLibraryBrowseItemsResponseModel result = await _controller.GetAutoTuneChannelMembers( + AutoTuneAxis.TvGenre, "Comedy", 2, 25, CancellationToken.None); + + result.ShouldBe(model); + await _mediator.Received(1).Send( + Arg.Is(q => + q.Axis == AutoTuneAxis.TvGenre && q.Value == "Comedy" && q.PageNum == 2 && q.PageSize == 25), + Arg.Any()); + } + + [Test] + public async Task GetAutoTuneChannelMembers_Should_Clamp_Paging() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new PagedLibraryBrowseItemsResponseModel(0, [])); + + // Negative page floors to 0; zero/oversized page size normalizes to the default/max. + await _controller.GetAutoTuneChannelMembers(AutoTuneAxis.MovieGenre, "Action", -3, 0, CancellationToken.None); + await _controller.GetAutoTuneChannelMembers(AutoTuneAxis.MovieGenre, "Action", 0, 9999, CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(q => q.PageNum == 0 && q.PageSize == 100), + Arg.Any()); + await _mediator.Received(1).Send( + Arg.Is(q => q.PageNum == 0 && q.PageSize == 200), + Arg.Any()); + } + private static PlayoutNameViewModel MakePlayout(int id, PlayoutScheduleKind scheduleKind) => new( id, diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index a2925f2ed..6a982c231 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -7,6 +7,7 @@ using ErsatzTV.Application.Templates; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Scheduling; @@ -244,6 +245,29 @@ public class ChannelController( return new OkObjectResult(ProjectToResponseModel(result)); } + [HttpGet("/api/v1/channels/auto-tune/members", Name = "GetAutoTuneChannelMembers")] + [Tags("Channels")] + [EndpointSummary("List a proposed auto-tune channel's distinct content-source members")] + [EndpointDescription( + "Given an auto-tune axis and value, returns the distinct content sources (parent shows for the " + + "TV axes, movies for the movie-genre axis) the server-generated SmartCollection query resolves " + + "to, with a per-source item count. Read-only; the server owns query generation.")] + [EndpointGroupName("general")] + [ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)] + public async Task GetAutoTuneChannelMembers( + [FromQuery] AutoTuneAxis axis, + [FromQuery] string value, + [FromQuery] int pageNum, + [FromQuery] int pageSize, + CancellationToken cancellationToken) + { + pageNum = Math.Max(0, pageNum); + pageSize = pageSize <= 0 ? 100 : Math.Min(pageSize, 200); + return await mediator.Send( + new GetAutoTuneChannelMembers(axis, value, pageNum, pageSize), + cancellationToken); + } + private static AutoTuneProposalResponseModel ProjectToResponseModel(AutoTuneProposal p) => new(p.Axis.ToString(), p.Value, p.Name, p.Number, p.ItemCount, p.AlreadyExists); diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 2af953f90..f5226eaad 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -2551,6 +2551,93 @@ ] } }, + "/api/v1/channels/auto-tune/members": { + "get": { + "tags": [ + "Channels" + ], + "summary": "List a proposed auto-tune channel's distinct content-source members", + "description": "Given an auto-tune axis and value, returns the distinct content sources (parent shows for the TV axes, movies for the movie-genre axis) the server-generated SmartCollection query resolves to, with a per-source item count. Read-only; the server owns query generation.", + "operationId": "GetAutoTuneChannelMembers", + "parameters": [ + { + "name": "axis", + "in": "query", + "schema": { + "$ref": "#/components/schemas/AutoTuneAxis" + } + }, + { + "name": "value", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "pageNum", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedLibraryBrowseItemsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedLibraryBrowseItemsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedLibraryBrowseItemsResponseModel" + } + } + } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, "/api/v1/channels/{id}/playout/reset": { "post": { "tags": [ diff --git a/docs/api-conventions.md b/docs/api-conventions.md index e0787bc8e..fdba3a13f 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -316,6 +316,21 @@ both requiring the standard credential (session-or-key, per §9 — no `[SkipApi | POST | `/api/v1/channels/auto-tune/preview` | `PreviewAutoTuneChannels` | Preview auto-tuned channels | | POST | `/api/v1/channels/auto-tune` | `CreateAutoTunedChannels` | Create auto-tuned channels | +**Endpoint inventory addition (#384, Auto-Tune DetailPanel backend)**: one read-only `ChannelController` +GET, standard credential (catalog-read tier — no `[RequiresAuthentication]`): + +| Method | Path | Operation | Summary | +|---|---|---|---| +| GET | `/api/v1/channels/auto-tune/members` | `GetAutoTuneChannelMembers` | List a proposed auto-tune channel's distinct content-source members | + +It takes `?axis=&value=&pageNum=&pageSize=` and reuses the existing `PagedLibraryBrowseItemsResponseModel` +/ `LibraryBrowseItemResponseModel` DTOs (no new schema). The handler runs the server-owned +`AutoTuneAxisMap.GenerateQuery(axis, value)` through `ISearchIndex.Search` (client never sends Lucene, per +the #69 PR1 decision), then rolls matching leaf items up to their distinct content sources — parent shows for +the TV axes (`ItemCount` = query-matching episodes, **not** the show's total), movies for the movie-genre +axis. Paging is clamped (`pageNum` floored at 0; `pageSize` defaults to 100, clamped 1–200) per the §1 Logs +precedent. See `docs/decisions.md` 2026-07-17 (#384) for the search-index-vs-EF-enumeration rationale. + **Resolved wart (#287)**: `DayOfWeek` previously serialized as an integer in the OpenAPI schema while the runtime JSON payload is the enum's **name string** ("Sunday".."Saturday"). It is now added to `Startup.UseStringEnumSchemas`'s hand-list, so the "v1" schema emits it as a **string enum** matching diff --git a/docs/decisions.md b/docs/decisions.md index 434017e5b..8927699ea 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -86,6 +86,7 @@ in-file entries. - [2026-07-17 — Clock-boundary schedule padding already exists (FillerMode.Pad); #77 verified, convenience toggle deferred](#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred) - [2026-07-17 — Shuffle-source construction extracted to `ShuffleSourceBuilder`; per-family seam, not a god-factory (#380)](#2026-07-17--shuffle-source-construction-extracted-to-shufflesourcebuilder-per-family-seam-not-a-god-factory-380) - [2026-07-17 — Seasonal / date-conditional scheduling already exists (alternate schedules / playout templates); #73 closed as implemented](#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented) +- [2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384)](#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384) --- @@ -1198,3 +1199,29 @@ channel" has no reason to look under "Alternate Schedules", and on finding that Fixed with a task-shaped **"Recipe: seasonal / holiday programming"** section in `channels.md` (both engines, plus the gotchas above) and a `domain-model.md` glossary row. No production code changed, so no live-E2E (same reasoning as #77). +## 2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384) + +The Auto-Tune DetailPanel (#383) shows, per proposed channel, the distinct **content sources** its +generated SmartCollection resolves to (a genre channel's shows/movies), each weightable in the #385 +write path. `GET /api/v1/channels/auto-tune/members?axis=&value=` backs that list. + +**Why the search index, not an EF distinct+count query** — even though #69's *preview* enumeration uses +EF (`PreviewAutoTuneChannelsHandler.EnumerateTvShows/…`). The created channel's playout is built from a +**SmartCollection** whose members come from `ISearchIndex.Search` (`MediaCollectionRepository.GetSmartCollectionItems`). +For the DetailPanel to faithfully preview *what the built channel will actually contain*, the member list +must run the **same** query through the **same** index — so the handler calls the server-owned +`AutoTuneAxisMap.GenerateQuery(axis, value)` (client never sends Lucene, per #69 PR1) and rolls the matching +leaf items up to their distinct parents. #69's preview is a different granularity (enumerating candidate +axis *values* with EF exact counts to drive the min-items threshold); this is enumerating the *members of one +value*, where index-fidelity matters more than count-exactness. The two coexist deliberately. + +**Roll-up + shape.** Episode axes (TvShow/TvGenre) → distinct parent shows (Episode→Season→ShowId), with +`ItemCount` = the **query-matching** episode count, not the show's total (a show contributes only its matching +episodes to a genre channel). Movie-genre axis → the matching movies are themselves the sources. Both reuse +the existing `PagedLibraryBrowseItemsResponseModel` / `LibraryBrowseItemResponseModel` DTOs and +`LibraryBrowseItemMapper.GetShows/GetMovies` (no new schema), ordered by title then id, paged in memory +(the distinct-source set is bounded — dozens for a genre). Search pulls up to the 10k cap +`GetSmartCollectionItems` already uses; a value resolving to >10k leaf items could under-report sources past +the cap — the same staleness bound the smart-collection path accepts. Read-only, catalog-read tier (no +`[RequiresAuthentication]`), so a cold review sufficed. Sibling backend child #385 (write-path overrides + +weights) and SPA child #386 remain open under the #383 milestone. diff --git a/docs/domain-model.md b/docs/domain-model.md index 2df09aa38..6e48bdb14 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -78,7 +78,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe | **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` | | **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) | | **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` | -| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69) | +| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384) | | **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. | `GetChannelGuideHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` | | **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — | | **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) | diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index 3c3d2610c..c3f809cab 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -2,7 +2,7 @@ *Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.* -164 endpoints, 247 operations. +165 endpoints, 248 operations. ## Artists @@ -56,6 +56,7 @@ |---|---|---|---| | POST | `/api/v1/channels` | ChannelCreate | Create a channel | | POST | `/api/v1/channels/auto-tune` | CreateAutoTunedChannels | Create auto-tuned channels | +| GET | `/api/v1/channels/auto-tune/members` | GetAutoTuneChannelMembers | List a proposed auto-tune channel's distinct content-source members | | POST | `/api/v1/channels/auto-tune/preview` | PreviewAutoTuneChannels | Preview auto-tuned channels | | POST | `/api/v1/channels/bulk/delete` | ChannelBulkDelete | Delete channels | | POST | `/api/v1/channels/bulk/group` | ChannelBulkMoveToGroup | Move channels to a group |