Merge pull request 'feat(384): auto-tune DetailPanel content-source member read endpoint' (#397) from feat/384-smartcollection-members into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 29m57s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 29m57s
This commit was merged in pull request #397.
This commit is contained in:
@@ -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<PagedLibraryBrowseItemsResponseModel>;
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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<ISearchIndex>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private void ReturnsSearch(params SearchItem[] items) =>
|
||||
_searchIndex.Search(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<CancellationToken>())
|
||||
.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<string>(q => q == "type:episode AND genre:\"Comedy\""),
|
||||
string.Empty,
|
||||
0,
|
||||
10_000,
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
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 = []
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -471,6 +471,49 @@ public class ChannelControllerTests
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetAutoTuneChannelMembers_Route_Is_Literal_And_Precedes_Id()
|
||||
{
|
||||
MethodInfo members = typeof(ChannelController).GetMethod(nameof(ChannelController.GetAutoTuneChannelMembers))!;
|
||||
members.GetCustomAttributes<HttpMethodAttribute>(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<GetAutoTuneChannelMembers>(), Arg.Any<CancellationToken>())
|
||||
.Returns(model);
|
||||
|
||||
PagedLibraryBrowseItemsResponseModel result = await _controller.GetAutoTuneChannelMembers(
|
||||
AutoTuneAxis.TvGenre, "Comedy", 2, 25, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(model);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetAutoTuneChannelMembers>(q =>
|
||||
q.Axis == AutoTuneAxis.TvGenre && q.Value == "Comedy" && q.PageNum == 2 && q.PageSize == 25),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAutoTuneChannelMembers_Should_Clamp_Paging()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAutoTuneChannelMembers>(), Arg.Any<CancellationToken>())
|
||||
.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<GetAutoTuneChannelMembers>(q => q.PageNum == 0 && q.PageSize == 100),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<GetAutoTuneChannelMembers>(q => q.PageNum == 0 && q.PageSize == 200),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static PlayoutNameViewModel MakePlayout(int id, PlayoutScheduleKind scheduleKind) =>
|
||||
new(
|
||||
id,
|
||||
|
||||
@@ -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<PagedLibraryBrowseItemsResponseModel> 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);
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user