Auto-tune channels can now carry per-content-source rotation weights (weighted
round-robin, e.g. 3x Show A / 1x Show B) and query corrections (exclude /
add-untagged), supplied at bulk-create time via an optional
`sources: [{sourceId, weight, excluded}]` on each AutoTunedChannelRequest.
Design (Option A, reuse #70): when a source is customized the channel is backed
by a system-owned MultiCollection of per-source SmartCollections carrying the
weights, with PlaybackOrder.WeightedShuffle -- the exact path
WeightedShuffleCollectionEnumerator already consumes. All-default weights keep
the #69 single-SmartCollection fair-share shape.
- Discriminators: TV -> live show_title:"X" (episodes carry no parent-show id in
the index); movies -> stable id:{mediaItemId}.
- Materialization is axis-dependent: TV materializes every base show individually
(un-weighted shows keep per-show fair-share) + a live remainder at weight 1;
MovieGenre materializes only touched movies + one count-weighted remainder.
- Remainder = (base) AND NOT (materialized union excluded) -- a partition.
- New nullable OwnedByChannelId on SmartCollection + MultiCollection
(dual-provider migration); owned rows are hidden from the collection lists and
cascade-cleaned on channel delete.
Tests: AutoTuneAxisMap query/partition units; DB-backed weighted-path handler
tests (TV materialize-all, movie count-remainder, exclusion, no-customization
fallback); delete-cleanup. Docs: decisions.md, domain-model.md, api-conventions.md;
OpenAPI trio regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
9.3 KiB
C#
213 lines
9.3 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ErsatzTV.Application.Channels;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Api.Channels;
|
|
using ErsatzTV.Application.MediaCollections;
|
|
using ErsatzTV.Core.Api.LibraryBrowse;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Search;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Tests.Support;
|
|
using LanguageExt;
|
|
using MediatR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Application.Channels;
|
|
|
|
// #425: per-source rotation weights turn an auto-tune channel from a single fair-share SmartCollection into
|
|
// a system-owned MultiCollection of per-source SmartCollections. CreateChannelFromLineup is mocked, so these
|
|
// verify the plan/persistence/ownership; end-to-end weighted round-robin is covered by
|
|
// WeightedShuffleCollectionEnumeratorTests + live-E2E.
|
|
[TestFixture]
|
|
public class CreateAutoTunedWeightedChannelsTests : ChannelHandlerTestBase
|
|
{
|
|
private ISender _mediator = null!;
|
|
private ISmartCollectionCache _smartCollectionCache = null!;
|
|
private CreateChannelFromLineup _sentLineup;
|
|
|
|
[SetUp]
|
|
public void WeightedSetUp()
|
|
{
|
|
_mediator = Substitute.For<ISender>();
|
|
_smartCollectionCache = Substitute.For<ISmartCollectionCache>();
|
|
_sentLineup = null;
|
|
|
|
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
|
|
.Returns(ci =>
|
|
{
|
|
_sentLineup = ci.Arg<CreateChannelFromLineup>();
|
|
return (Either<BaseError, CreateChannelFromLineupResponseModel>)
|
|
new CreateChannelFromLineupResponseModel(88, null, 1, 2);
|
|
});
|
|
}
|
|
|
|
private void SeedMembers(params (int Id, string Title)[] members) =>
|
|
_mediator.Send(Arg.Any<GetAutoTuneChannelMembers>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedLibraryBrowseItemsResponseModel(
|
|
members.Length,
|
|
members.Select(m => Item(m.Id, m.Title)).ToList()));
|
|
|
|
private static LibraryBrowseItemResponseModel Item(int id, string title) =>
|
|
new(id, LibraryBrowseMediaType.TelevisionShow, title, null, null, string.Empty, null, null, null,
|
|
CollectionType.SmartCollection, null, null, null, null, null, null);
|
|
|
|
private CreateAutoTunedChannelsHandler MakeHandler() =>
|
|
new(_mediator, Db.Factory, SearchTargets, _smartCollectionCache);
|
|
|
|
private async Task<(MultiCollection Mc, List<SmartCollection> Members)> LoadOnlyMultiCollection()
|
|
{
|
|
await using TvContext context = Db.CreateContext();
|
|
MultiCollection mc = await context.MultiCollections
|
|
.Include(m => m.MultiCollectionSmartItems)
|
|
.ThenInclude(i => i.SmartCollection)
|
|
.SingleAsync();
|
|
List<SmartCollection> members = mc.MultiCollectionSmartItems
|
|
.OrderBy(i => i.SmartCollection.Name)
|
|
.Select(i => i.SmartCollection)
|
|
.ToList();
|
|
return (mc, members);
|
|
}
|
|
|
|
[Test]
|
|
public async Task TvGenre_Materializes_Every_Show_With_A_Live_Remainder()
|
|
{
|
|
SeedMembers((1, "Alpha"), (2, "Beta"), (3, "Gamma"));
|
|
|
|
AutoTuneResult result = await MakeHandler().Handle(
|
|
new CreateAutoTunedChannels(3, "Auto-Tuned", new List<AutoTuneChannelSelection>
|
|
{
|
|
new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500",
|
|
Sources: [new AutoTuneSourceWeight(1, Weight: 3)])
|
|
}),
|
|
CancellationToken.None);
|
|
|
|
result.CreatedCount.ShouldBe(1);
|
|
|
|
(MultiCollection mc, List<SmartCollection> _) = await LoadOnlyMultiCollection();
|
|
|
|
// 3 shows + 1 remainder, all owned by the created channel.
|
|
mc.MultiCollectionSmartItems.Count.ShouldBe(4);
|
|
mc.OwnedByChannelId.ShouldBe(88);
|
|
|
|
Dictionary<string, int> weightByQuery = mc.MultiCollectionSmartItems
|
|
.ToDictionary(i => i.SmartCollection.Query, i => i.Weight);
|
|
|
|
weightByQuery["type:episode AND show_title:\"Alpha\""].ShouldBe(3);
|
|
weightByQuery["type:episode AND show_title:\"Beta\""].ShouldBe(1);
|
|
weightByQuery["type:episode AND show_title:\"Gamma\""].ShouldBe(1);
|
|
weightByQuery[
|
|
"(type:episode AND genre:\"Comedy\") AND NOT (show_title:\"Alpha\" OR show_title:\"Beta\" OR show_title:\"Gamma\")"]
|
|
.ShouldBe(1);
|
|
|
|
// The channel points at the MultiCollection with WeightedShuffle.
|
|
_sentLineup.Advanced.PlaybackOrder.ShouldBe(PlaybackOrder.WeightedShuffle);
|
|
_sentLineup.Lineup.Count.ShouldBe(1);
|
|
_sentLineup.Lineup[0].CollectionType.ShouldBe(CollectionType.MultiCollection);
|
|
_sentLineup.Lineup[0].MultiCollectionId.ShouldBe(mc.Id);
|
|
|
|
// Every member smart collection is stamped as owned (hidden from user lists, cleaned on delete).
|
|
mc.MultiCollectionSmartItems.ShouldAllBe(i => i.SmartCollection.OwnedByChannelId == 88);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Excluded_Show_Is_Not_A_Member_But_Is_Subtracted_From_The_Remainder()
|
|
{
|
|
SeedMembers((1, "Alpha"), (2, "Beta"), (3, "Gamma"));
|
|
|
|
await MakeHandler().Handle(
|
|
new CreateAutoTunedChannels(3, "Auto-Tuned", new List<AutoTuneChannelSelection>
|
|
{
|
|
new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500",
|
|
Sources: [new AutoTuneSourceWeight(2, Excluded: true)])
|
|
}),
|
|
CancellationToken.None);
|
|
|
|
(MultiCollection mc, List<SmartCollection> members) = await LoadOnlyMultiCollection();
|
|
|
|
// Beta is dropped: 2 shows + 1 remainder.
|
|
members.Select(m => m.Query).ShouldNotContain("type:episode AND show_title:\"Beta\"");
|
|
mc.MultiCollectionSmartItems.Count(i => !i.SmartCollection.Query.StartsWith("(")).ShouldBe(2);
|
|
|
|
// But Beta is still subtracted so its episodes don't leak into the remainder.
|
|
string remainder = members.Single(m => m.Query.StartsWith("(")).Query;
|
|
remainder.ShouldContain("show_title:\"Beta\"");
|
|
}
|
|
|
|
[Test]
|
|
public async Task MovieGenre_Materializes_Only_Touched_Movies_With_A_Count_Weighted_Remainder()
|
|
{
|
|
// 4 movies; only movie 10 is re-weighted. The untouched three stay in one count-weighted remainder.
|
|
_mediator.Send(Arg.Any<GetAutoTuneChannelMembers>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedLibraryBrowseItemsResponseModel(4, new List<LibraryBrowseItemResponseModel>
|
|
{
|
|
MovieItem(10), MovieItem(11), MovieItem(12), MovieItem(13)
|
|
}));
|
|
|
|
await MakeHandler().Handle(
|
|
new CreateAutoTunedChannels(3, "Auto-Tuned", new List<AutoTuneChannelSelection>
|
|
{
|
|
new(AutoTuneAxis.MovieGenre, "Action", "Action Movies", "500",
|
|
Sources: [new AutoTuneSourceWeight(10, Weight: 3)])
|
|
}),
|
|
CancellationToken.None);
|
|
|
|
(MultiCollection mc, List<SmartCollection> members) = await LoadOnlyMultiCollection();
|
|
|
|
// one materialized movie + one remainder
|
|
mc.MultiCollectionSmartItems.Count.ShouldBe(2);
|
|
|
|
MultiCollectionSmartItem movie = mc.MultiCollectionSmartItems
|
|
.Single(i => i.SmartCollection.Query == "type:movie AND id:10");
|
|
movie.Weight.ShouldBe(3);
|
|
|
|
// remainder weight = the 3 un-touched base movies; subtracts the materialized movie by id
|
|
MultiCollectionSmartItem remainder = mc.MultiCollectionSmartItems
|
|
.Single(i => i.SmartCollection.Query.StartsWith("("));
|
|
remainder.Weight.ShouldBe(3);
|
|
remainder.SmartCollection.Query.ShouldBe("(type:movie AND genre:\"Action\") AND NOT (id:10)");
|
|
}
|
|
|
|
[Test]
|
|
public async Task All_Default_Weights_Fall_Back_To_A_Single_SmartCollection()
|
|
{
|
|
SeedMembers((1, "Alpha"), (2, "Beta"));
|
|
|
|
// Sources present but every weight is the fair-share default and nothing is excluded/added: the
|
|
// channel keeps the cheap single-SmartCollection shape (no MultiCollection created).
|
|
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
|
|
.Returns(ci => (Either<BaseError, SmartCollectionViewModel>)
|
|
new SmartCollectionViewModel(7, ci.Arg<CreateSmartCollection>().Name,
|
|
ci.Arg<CreateSmartCollection>().Query));
|
|
|
|
AutoTuneResult result = await MakeHandler().Handle(
|
|
new CreateAutoTunedChannels(3, "Auto-Tuned", new List<AutoTuneChannelSelection>
|
|
{
|
|
new(AutoTuneAxis.TvGenre, "Comedy", "Comedy", "500",
|
|
Sources:
|
|
[
|
|
new AutoTuneSourceWeight(1),
|
|
new AutoTuneSourceWeight(2)
|
|
])
|
|
}),
|
|
CancellationToken.None);
|
|
|
|
result.CreatedCount.ShouldBe(1);
|
|
|
|
await using TvContext context = Db.CreateContext();
|
|
(await context.MultiCollections.AnyAsync()).ShouldBeFalse();
|
|
|
|
_sentLineup.Lineup[0].CollectionType.ShouldBe(CollectionType.SmartCollection);
|
|
_sentLineup.Lineup[0].SmartCollectionId.ShouldBe(7);
|
|
}
|
|
|
|
private static LibraryBrowseItemResponseModel MovieItem(int id) =>
|
|
new(id, LibraryBrowseMediaType.Movie, $"Movie {id}", null, null, string.Empty, null, null, null,
|
|
CollectionType.SmartCollection, null, null, null, null, null, null);
|
|
}
|