feat(425): per-source rotation weights + query corrections for auto-tune channels #439

Merged
timothy merged 2 commits from feat/425-autotune-weighted-sources into main 2026-07-18 04:29:17 +02:00
33 changed files with 15485 additions and 29 deletions
@@ -38,6 +38,58 @@ public static class AutoTuneAxisMap
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
// Per-source member query for a weighted auto-tune channel (#425). The discriminator identifies ONE
// content source within the channel's axis:
// * TV axes -> the show title. Episodes carry no parent-show id in the search index (only show_title
// is denormalized onto them), so show_title is the only field that selects a show's episodes. It is
// the same discriminator the TvShow axis already uses, so this introduces no new fragility class;
// a post-create show rename empties the member (items fall through to the remainder) until re-tuned.
// * MovieGenre -> the movie's media-item id (the stable, rename-proof `id` field; a movie IS the
// played item, so its own id selects it exactly).
// Deliberately discriminator-ONLY (no genre clause): membership is decided when the channel is tuned,
// so a materialized show airs all its episodes and the remainder subtracts the whole source (below).
public static string GenerateSourceQuery(AutoTuneAxis axis, string discriminator) =>
axis switch
{
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
$"type:episode AND show_title:\"{EscapeLuceneValue(discriminator)}\"",
AutoTuneAxis.MovieGenre => $"type:movie AND id:{discriminator}",
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
// The bare clause used to subtract a materialized/excluded source from the remainder query (below).
// Mirrors GenerateSourceQuery's discriminator field, minus the type prefix.
public static string SourceDiscriminatorClause(AutoTuneAxis axis, string discriminator) =>
axis switch
{
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
$"show_title:\"{EscapeLuceneValue(discriminator)}\"",
AutoTuneAxis.MovieGenre => $"id:{discriminator}",
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
};
// The catch-all remainder query: the base axis query minus every materialized/excluded source, so the
// base set is partitioned across (member sources + remainder) with no item counted twice and none
// dropped. Returns the plain base query when there is nothing to subtract. Emitted as valid classic
// Lucene — `(base) AND NOT (d1 OR d2 ...)` — because a ParseException silently escapes the whole query
// into a literal (SearchQueryParser.ParseQuery fallback).
public static string GenerateRemainderQuery(
AutoTuneAxis axis,
string value,
IReadOnlyCollection<string> subtractedDiscriminators)
{
string baseQuery = GenerateQuery(axis, value);
if (subtractedDiscriminators is null || subtractedDiscriminators.Count == 0)
{
return baseQuery;
}
string negated = string.Join(
" OR ",
subtractedDiscriminators.Select(d => SourceDiscriminatorClause(axis, d)));
return $"({baseQuery}) AND NOT ({negated})";
}
// Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote.
public static string EscapeLuceneValue(string value) =>
(value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
@@ -42,6 +42,16 @@ public class BulkDeleteChannelsHandler(
dbContext.Channels.RemoveRange(channels);
await dbContext.SaveChangesAsync(cancellationToken);
// Clean up the system-owned weighted-auto-tune artifacts these channels created (#425), inside the
// same transaction — see DeleteChannelHandler for the cascade rationale.
await dbContext.MultiCollections
.Where(mc => mc.OwnedByChannelId != null && channelIds.Contains(mc.OwnedByChannelId.Value))
.ExecuteDeleteAsync(cancellationToken);
await dbContext.SmartCollections
.Where(sc => sc.OwnedByChannelId != null && channelIds.Contains(sc.OwnedByChannelId.Value))
.ExecuteDeleteAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
@@ -57,9 +57,22 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
_fileSystem.File.Delete(cacheFile);
}
int channelId = channel.Id;
dbContext.Channels.Remove(channel);
await dbContext.SaveChangesAsync(cancellationToken);
// Clean up the system-owned weighted-auto-tune artifacts this channel created (#425): the
// MultiCollection (its cascade removes the now-dangling flood schedule item) and its per-source
// SmartCollections (cascade removes their join rows). Null OwnedByChannelId = a user collection, left
// untouched. Non-weighted (#69 single-SmartCollection) auto-tune channels set no ownership, so their
// pre-existing orphan-on-delete behavior is unchanged.
await dbContext.MultiCollections
.Where(mc => mc.OwnedByChannelId == channelId)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.SmartCollections
.Where(sc => sc.OwnedByChannelId == channelId)
.ExecuteDeleteAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
// refresh channel list to remove channel that has no playout — post-commit side effect runs on
@@ -19,7 +19,17 @@ public record AutoTuneChannelSelection(
string Number,
int? TemplateId = null,
ArtworkContentTypeModel Logo = null,
CreateChannelFromLineupAdvancedOptions Advanced = null);
CreateChannelFromLineupAdvancedOptions Advanced = null,
List<AutoTuneSourceWeight> Sources = null);
// Per-content-source rotation weight + query correction for a weighted auto-tune channel (#425).
// SourceId is the show id (TV axes) or movie media-item id (movie axis) from the members list (#384).
// Weight is the relative share of airtime (weighted round-robin; 1 = fair-share). Excluded drops the
// source entirely. A SourceId that is not in the axis's base set is an "add-untagged" source — materialized
// like any other. When every entry is Weight 1 and not excluded (and adds nothing), the channel keeps the
// single-SmartCollection fair-share shape; otherwise it is built as a MultiCollection of per-source
// SmartCollections carrying the weights.
public record AutoTuneSourceWeight(int SourceId, int Weight = 1, bool Excluded = false);
public record AutoTuneResult(List<AutoTuneChannelOutcome> Results)
{
@@ -4,17 +4,29 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class CreateAutoTunedChannelsHandler(ISender mediator)
public class CreateAutoTunedChannelsHandler(
ISender mediator,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
ISmartCollectionCache smartCollectionCache)
: IRequestHandler<CreateAutoTunedChannels, AutoTuneResult>
{
private const string NumberTakenError = "Channel number must be unique";
private const string DefaultGroup = "Auto-Tuned";
// The members enumeration caps its own search at 10k leaf items, so a channel's distinct source count is
// already bounded (dozens/hundreds). One large page pulls them all.
private const int MaxSources = 10_000;
public async Task<AutoTuneResult> Handle(
CreateAutoTunedChannels request,
CancellationToken cancellationToken)
@@ -42,6 +54,41 @@ public class CreateAutoTunedChannelsHandler(ISender mediator)
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Failed, null, "Invalid channel name");
}
// Per-channel template override falls back to the batch template.
int effectiveTemplateId = selection.TemplateId ?? templateId;
// Per-channel uploaded channel image; None = generate the on-the-fly fallback logo at serve time.
ArtworkContentTypeModel logo = selection.Logo ?? ArtworkContentTypeModel.None;
// Per-source rotation weights / query corrections (#425) turn the channel from one fair-share
// SmartCollection into a MultiCollection of per-source SmartCollections carrying the weights. Only
// when the caller actually customized a source (a non-default weight, an exclusion, or an added
// out-of-axis source) — otherwise the single-SmartCollection fair-share shape is kept (cheaper, and
// identical output for TV since the fake-collection path already groups per show).
WeightedPlan plan = await BuildWeightedPlan(selection, cancellationToken);
if (plan is not null)
{
return await CreateWeightedChannel(
effectiveTemplateId, group, name, logo, selection, plan, cancellationToken);
}
return await CreateSingleSmartCollectionChannel(
effectiveTemplateId,
group,
name,
logo,
selection,
cancellationToken);
}
private async Task<AutoTuneChannelOutcome> CreateSingleSmartCollectionChannel(
int effectiveTemplateId,
string group,
string name,
ArtworkContentTypeModel logo,
AutoTuneChannelSelection selection,
CancellationToken cancellationToken)
{
string query = AutoTuneAxisMap.GenerateQuery(selection.Axis, selection.Value);
// The axis default (SeasonEpisode for a single show, Shuffle for a genre) is the playback order
@@ -55,12 +102,6 @@ public class CreateAutoTunedChannelsHandler(ISender mediator)
PlaybackOrder = selection.Advanced?.PlaybackOrder ?? axisOrder
};
// Per-channel template override falls back to the batch template.
int effectiveTemplateId = selection.TemplateId ?? templateId;
// Per-channel uploaded channel image; None = generate the on-the-fly fallback logo at serve time.
ArtworkContentTypeModel logo = selection.Logo ?? ArtworkContentTypeModel.None;
// 1. Create the smart collection that drives this channel.
Either<BaseError, SmartCollectionViewModel> scResult =
await mediator.Send(new CreateSmartCollection(query, name), cancellationToken);
@@ -129,4 +170,350 @@ public class CreateAutoTunedChannelsHandler(ISender mediator)
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
}
// A resolved weighting plan: the per-source member queries + their weights, and the catch-all remainder.
// Null when the caller did not actually customize anything (fall back to the single-SmartCollection path).
private sealed record WeightedPlan(List<WeightedMember> Members, WeightedMember Remainder);
private sealed record WeightedMember(string Query, int Weight);
// Resolve the caller's per-source overrides against the channel's live base source set. Returns null when
// no source was customized (all weights 1, nothing excluded, nothing added) so the caller keeps the
// single-SmartCollection fair-share shape.
private async Task<WeightedPlan> BuildWeightedPlan(
AutoTuneChannelSelection selection,
CancellationToken cancellationToken)
{
List<AutoTuneSourceWeight> sources = selection.Sources ?? [];
if (sources.Count == 0)
{
return null;
}
// Enumerate the axis's distinct base sources (parent shows for TV, movies for the movie axis) exactly
// as the DetailPanel members list does, so weight resolution matches what the user saw.
PagedLibraryBrowseItemsResponseModel members = await mediator.Send(
new GetAutoTuneChannelMembers(selection.Axis, selection.Value, 0, MaxSources),
cancellationToken);
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
// Any override touching a non-default weight, an exclusion, or an id outside the base set means the
// channel really is customized; otherwise the plan would be identical to fair-share.
bool customized = sources.Any(s => s.Weight != 1 || s.Excluded || !baseIds.Contains(s.SourceId));
if (!customized)
{
return null;
}
Dictionary<int, AutoTuneSourceWeight> overridesById = sources
.GroupBy(s => s.SourceId)
.ToDictionary(g => g.Key, g => g.Last());
return selection.Axis switch
{
AutoTuneAxis.MovieGenre => BuildMoviePlan(selection, members, overridesById),
_ => await BuildTvPlan(selection, members, overridesById, cancellationToken)
};
}
// TV: every base show becomes its own weighted SmartCollection (discriminator-only `show_title`) so
// un-weighted shows keep per-show fair-share — a single merged remainder would regress them to
// item-proportional (a 200-episode show would swamp a 20-episode one). The remainder is the live
// catch-all for shows/episodes added after tune-in, at weight 1.
private async Task<WeightedPlan> BuildTvPlan(
AutoTuneChannelSelection selection,
PagedLibraryBrowseItemsResponseModel members,
Dictionary<int, AutoTuneSourceWeight> overridesById,
CancellationToken cancellationToken)
{
var weightedMembers = new List<WeightedMember>();
var subtracted = new List<string>();
// Base shows (title is the discriminator; the members list already carries it).
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
foreach (LibraryBrowseItemResponseModel item in members.Page)
{
AutoTuneSourceWeight ov = overridesById.GetValueOrDefault(item.Id);
if (ov is { Excluded: true })
{
subtracted.Add(item.Title);
continue;
}
weightedMembers.Add(new WeightedMember(
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, item.Title),
NormalizeWeight(ov?.Weight ?? 1)));
subtracted.Add(item.Title);
}
// Added (out-of-axis) shows: resolve the title from metadata since the members list won't include them.
List<int> addedIds = overridesById.Keys.Where(id => !baseIds.Contains(id)).ToList();
if (addedIds.Count > 0)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Dictionary<int, string> titles = (await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => addedIds.Contains(sm.ShowId))
.Select(sm => new { sm.ShowId, sm.Title })
.ToListAsync(cancellationToken))
.GroupBy(x => x.ShowId)
.ToDictionary(g => g.Key, g => g.First().Title);
foreach (int id in addedIds)
{
AutoTuneSourceWeight ov = overridesById[id];
if (ov.Excluded || !titles.TryGetValue(id, out string title) || string.IsNullOrWhiteSpace(title))
{
continue;
}
weightedMembers.Add(new WeightedMember(
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, title),
NormalizeWeight(ov.Weight)));
subtracted.Add(title);
}
}
var remainder = new WeightedMember(
AutoTuneAxisMap.GenerateRemainderQuery(selection.Axis, selection.Value, subtracted),
1);
return new WeightedPlan(weightedMembers, remainder);
}
// Movies: materialize only the touched movies (a non-default weight, or an added out-of-axis movie) as
// individual `id:{n}` SmartCollections; every un-touched base movie stays in ONE remainder whose weight is
// its member count. Because the fake-collection path already pools all movies uniformly, a count-weighted
// remainder is exactly equivalent to materializing each movie individually — without hundreds of rows.
private static WeightedPlan BuildMoviePlan(
AutoTuneChannelSelection selection,
PagedLibraryBrowseItemsResponseModel members,
Dictionary<int, AutoTuneSourceWeight> overridesById)
{
var weightedMembers = new List<WeightedMember>();
var subtracted = new List<string>();
var baseIds = members.Page.Select(i => i.Id).ToHashSet();
var subtractedBase = 0;
foreach ((int id, AutoTuneSourceWeight ov) in overridesById)
{
bool inBase = baseIds.Contains(id);
string idClause = id.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (ov.Excluded)
{
subtracted.Add(idClause);
if (inBase)
{
subtractedBase++;
}
continue;
}
// Materialize weighted base movies and every added (out-of-axis) movie; a base movie left at
// weight 1 is cheaper to leave in the remainder (same airtime either way).
if (ov.Weight != 1 || !inBase)
{
weightedMembers.Add(new WeightedMember(
AutoTuneAxisMap.GenerateSourceQuery(selection.Axis, idClause),
NormalizeWeight(ov.Weight)));
subtracted.Add(idClause);
if (inBase)
{
subtractedBase++;
}
}
}
// Remainder weight = the un-touched base movie count, so a weighted movie airs N× *each* remainder
// movie (the fake path already pools movies uniformly, so this is equivalent to materializing each).
// Clamped to MultiCollectionItemWeight.Maximum (1000): a genre with >1000 un-touched movies can't
// express the exact ratio (the weighted movie then airs slightly more than intended) — the same
// 1..1000 bound #70's weight column imposes everywhere. Realistic only at very large scale.
int remainderCount = baseIds.Count - subtractedBase;
var remainder = new WeightedMember(
AutoTuneAxisMap.GenerateRemainderQuery(selection.Axis, selection.Value, subtracted),
NormalizeWeight(remainderCount));
return new WeightedPlan(weightedMembers, remainder);
}
private static int NormalizeWeight(int weight) =>
Math.Clamp(weight, MultiCollectionItemWeight.Minimum, MultiCollectionItemWeight.Maximum);
private async Task<AutoTuneChannelOutcome> CreateWeightedChannel(
int effectiveTemplateId,
string group,
string name,
ArtworkContentTypeModel logo,
AutoTuneChannelSelection selection,
WeightedPlan plan,
CancellationToken cancellationToken)
{
// WeightedShuffle is the whole point; it overrides any axis default / caller Advanced.PlaybackOrder.
CreateChannelFromLineupAdvancedOptions advanced =
(selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with
{
PlaybackOrder = PlaybackOrder.WeightedShuffle
};
// Short unique token: the channel id isn't known until CreateChannelFromLineup runs, and both
// SmartCollection.Name and MultiCollection.Name are unique varchar(50).
string token = Guid.NewGuid().ToString("N")[..8];
int multiCollectionId;
List<int> smartCollectionIds;
await using (TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken))
{
var multiCollection = new MultiCollection
{
Name = $"at-mc:{token}",
MultiCollectionItems = [],
MultiCollectionSmartItems = []
};
var index = 0;
foreach (WeightedMember member in plan.Members.Append(plan.Remainder))
{
var smartCollection = new SmartCollection
{
Name = index == plan.Members.Count ? $"at:{token}:rem" : $"at:{token}:{index}",
Query = member.Query
};
dbContext.SmartCollections.Add(smartCollection);
multiCollection.MultiCollectionSmartItems.Add(new MultiCollectionSmartItem
{
MultiCollection = multiCollection,
SmartCollection = smartCollection,
ScheduleAsGroup = false,
PlaybackOrder = PlaybackOrder.Shuffle,
Weight = member.Weight
});
index++;
}
dbContext.MultiCollections.Add(multiCollection);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
return new AutoTuneChannelOutcome(
name, AutoTuneOutcomeStatus.Failed, null, $"Weighted collections: {ex.Message}");
}
multiCollectionId = multiCollection.Id;
smartCollectionIds = multiCollection.MultiCollectionSmartItems
.Select(i => i.SmartCollectionId)
.ToList();
// New smart collections became visible; refresh targets + cache like CreateSmartCollectionHandler
// (post-commit, CancellationToken.None so a late cancel can't abort it after the commit landed).
searchTargets.SearchTargetsChanged();
await smartCollectionCache.Refresh(CancellationToken.None);
}
var command = new CreateChannelFromLineup(
name,
selection.Number,
group,
string.Empty,
logo,
IsEnabled: true,
ShowInEpg: true,
effectiveTemplateId,
advanced,
[
new CreateChannelFromLineupItem(
LibraryBrowseMediaType.MultiCollection,
CollectionType.MultiCollection,
CollectionId: null,
MultiCollectionId: multiCollectionId,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null)
]);
Either<BaseError, CreateChannelFromLineupResponseModel> channelResult =
await mediator.Send(command, cancellationToken);
foreach (BaseError error in channelResult.LeftToSeq())
{
// Roll back the multi collection + its member smart collections so a retry doesn't collide on
// name uniqueness. Best-effort; the outcome below stands regardless of the cleanup result.
await TryDeleteOwnedArtifacts(multiCollectionId, smartCollectionIds, cancellationToken);
AutoTuneOutcomeStatus status = error.Value.Contains(NumberTakenError, StringComparison.Ordinal)
? AutoTuneOutcomeStatus.Skipped
: AutoTuneOutcomeStatus.Failed;
return new AutoTuneChannelOutcome(name, status, null, error.Value);
}
int channelId = channelResult.Match(Left: _ => 0, Right: r => r.ChannelId);
// Stamp ownership so the artifacts are hidden from user collection lists and cleaned up on channel
// delete. Best-effort: an unstamped artifact is a cosmetic/cleanup issue, never a failed channel.
await TryStampOwnership(multiCollectionId, smartCollectionIds, channelId);
return new AutoTuneChannelOutcome(name, AutoTuneOutcomeStatus.Created, channelId, null);
}
private async Task TryStampOwnership(
int multiCollectionId,
List<int> smartCollectionIds,
int channelId)
{
try
{
// Post-commit side effect: runs on CancellationToken.None so a late request cancellation can't
// abort it after the channel-create commit landed (#254) — an un-stamped artifact would be a
// permanent orphan (never cleaned on delete, and visible in the user collection lists). The MC +
// its member smart collections are stamped in one transaction so a mid-way failure can't leave the
// MC owned while the smart collections stay orphaned.
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(CancellationToken.None);
await using var transaction = await dbContext.Database.BeginTransactionAsync(CancellationToken.None);
await dbContext.MultiCollections
.Where(mc => mc.Id == multiCollectionId)
.ExecuteUpdateAsync(s => s.SetProperty(mc => mc.OwnedByChannelId, channelId), CancellationToken.None);
await dbContext.SmartCollections
.Where(sc => smartCollectionIds.Contains(sc.Id))
.ExecuteUpdateAsync(s => s.SetProperty(sc => sc.OwnedByChannelId, channelId), CancellationToken.None);
await transaction.CommitAsync(CancellationToken.None);
}
catch (Exception)
{
// intentionally ignored; see call site
}
}
private async Task TryDeleteOwnedArtifacts(
int multiCollectionId,
List<int> smartCollectionIds,
CancellationToken cancellationToken)
{
try
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await dbContext.MultiCollections
.Where(mc => mc.Id == multiCollectionId)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.SmartCollections
.Where(sc => smartCollectionIds.Contains(sc.Id))
.ExecuteDeleteAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
await smartCollectionCache.Refresh(CancellationToken.None);
}
catch (Exception)
{
// intentionally ignored; see call site
}
}
}
@@ -1,4 +1,4 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -17,6 +17,7 @@ public class GetAllMultiCollectionsHandler : IRequestHandler<GetAllMultiCollecti
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.MultiCollections
.Where(mc => mc.OwnedByChannelId == null)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -16,6 +16,7 @@ public class GetAllSmartCollectionsForApiHandler(IDbContextFactory<TvContext> db
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<SmartCollection> ffmpegProfiles = await dbContext.SmartCollections
.AsNoTracking()
.Where(sc => sc.OwnedByChannelId == null)
.ToListAsync(cancellationToken);
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
}
@@ -1,4 +1,4 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -17,6 +17,7 @@ public class GetAllSmartCollectionsHandler : IRequestHandler<GetAllSmartCollecti
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.SmartCollections
.Where(sc => sc.OwnedByChannelId == null)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -13,9 +13,12 @@ public class GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.MultiCollections.CountAsync(cancellationToken);
int count = await dbContext.MultiCollections
.CountAsync(mc => mc.OwnedByChannelId == null, cancellationToken);
IQueryable<MultiCollection> query = dbContext.MultiCollections.AsNoTracking();
IQueryable<MultiCollection> query = dbContext.MultiCollections
.AsNoTracking()
.Where(mc => mc.OwnedByChannelId == null);
if (!string.IsNullOrWhiteSpace(request.Query))
{
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
@@ -13,9 +13,12 @@ public class GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbCont
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int count = await dbContext.SmartCollections.CountAsync(cancellationToken);
int count = await dbContext.SmartCollections
.CountAsync(sc => sc.OwnedByChannelId == null, cancellationToken);
IQueryable<SmartCollection> query = dbContext.SmartCollections.AsNoTracking();
IQueryable<SmartCollection> query = dbContext.SmartCollections
.AsNoTracking()
.Where(sc => sc.OwnedByChannelId == null);
if (!string.IsNullOrWhiteSpace(request.Query))
{
@@ -15,6 +15,9 @@ public class SearchMultiCollectionsHandler(IDbContextFactory<TvContext> dbContex
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.MultiCollections
.AsNoTracking()
// Hide system-owned auto-tune weighted artifacts (#425) from the scheduling picker: selecting one
// into a user schedule would let a later channel delete cascade away that schedule item.
.Where(mc => mc.OwnedByChannelId == null)
.Where(mc => EF.Functions.Like(mc.Name, $"%{request.Query}%"))
.OrderBy(mc => mc.Name)
.Take(10)
@@ -15,6 +15,9 @@ public class SearchSmartCollectionsHandler(IDbContextFactory<TvContext> dbContex
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.SmartCollections
.AsNoTracking()
// Hide system-owned auto-tune weighted artifacts (#425) from the scheduling picker: selecting one
// into a user schedule would let a later channel delete cascade away that schedule item.
.Where(sc => sc.OwnedByChannelId == null)
.Where(sc => EF.Functions.Like(sc.Name, $"%{request.Query}%"))
.OrderBy(sc => sc.Name)
.Take(10)
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.Core.Domain;
@@ -12,4 +12,12 @@ public class MultiCollection : IVersionedAggregate
public List<SmartCollection> SmartCollections { get; set; }
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
/// <summary>
/// When non-null, this multi collection is a system-owned artifact created for an auto-tune weighted
/// channel (#425): its members are the per-content-source SmartCollections that carry the rotation
/// weights. Hidden from the user-facing collection lists and deleted when the channel is deleted.
/// Null for every user-created multi collection.
/// </summary>
public int? OwnedByChannelId { get; set; }
}
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
namespace ErsatzTV.Core.Domain;
@@ -10,4 +10,12 @@ public class SmartCollection
public string Query { get; set; }
public List<MultiCollection> MultiCollections { get; set; }
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
/// <summary>
/// When non-null, this is a system-owned artifact created for an auto-tune weighted channel (#425):
/// one per-content-source SmartCollection (or the catch-all remainder) inside that channel's
/// MultiCollection. Hidden from the user-facing collection lists and deleted when the channel is
/// deleted. Null for every user-created smart collection.
/// </summary>
public int? OwnedByChannelId { get; set; }
}
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class AddCollectionOwnedByChannelId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "OwnedByChannelId",
table: "SmartCollection",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OwnedByChannelId",
table: "MultiCollection",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_SmartCollection_OwnedByChannelId",
table: "SmartCollection",
column: "OwnedByChannelId");
migrationBuilder.CreateIndex(
name: "IX_MultiCollection_OwnedByChannelId",
table: "MultiCollection",
column: "OwnedByChannelId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_SmartCollection_OwnedByChannelId",
table: "SmartCollection");
migrationBuilder.DropIndex(
name: "IX_MultiCollection_OwnedByChannelId",
table: "MultiCollection");
migrationBuilder.DropColumn(
name: "OwnedByChannelId",
table: "SmartCollection");
migrationBuilder.DropColumn(
name: "OwnedByChannelId",
table: "MultiCollection");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -1780,6 +1780,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("varchar(50)")
.UseCollation("utf8mb4_general_ci");
b.Property<int?>("OwnedByChannelId")
.HasColumnType("int");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("int");
@@ -1789,6 +1792,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OwnedByChannelId");
b.ToTable("MultiCollection", (string)null);
});
@@ -3525,6 +3530,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasColumnType("varchar(50)")
.UseCollation("utf8mb4_general_ci");
b.Property<int?>("OwnedByChannelId")
.HasColumnType("int");
b.Property<string>("Query")
.HasColumnType("longtext");
@@ -3533,6 +3541,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OwnedByChannelId");
b.ToTable("SmartCollection", (string)null);
});
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddCollectionOwnedByChannelId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "OwnedByChannelId",
table: "SmartCollection",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OwnedByChannelId",
table: "MultiCollection",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_SmartCollection_OwnedByChannelId",
table: "SmartCollection",
column: "OwnedByChannelId");
migrationBuilder.CreateIndex(
name: "IX_MultiCollection_OwnedByChannelId",
table: "MultiCollection",
column: "OwnedByChannelId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_SmartCollection_OwnedByChannelId",
table: "SmartCollection");
migrationBuilder.DropIndex(
name: "IX_MultiCollection_OwnedByChannelId",
table: "MultiCollection");
migrationBuilder.DropColumn(
name: "OwnedByChannelId",
table: "SmartCollection");
migrationBuilder.DropColumn(
name: "OwnedByChannelId",
table: "MultiCollection");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -1703,6 +1703,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("varchar(50)")
.UseCollation("NOCASE");
b.Property<int?>("OwnedByChannelId")
.HasColumnType("INTEGER");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
@@ -1712,6 +1715,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OwnedByChannelId");
b.ToTable("MultiCollection", (string)null);
});
@@ -3372,6 +3377,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasColumnType("varchar(50)")
.UseCollation("NOCASE");
b.Property<int?>("OwnedByChannelId")
.HasColumnType("INTEGER");
b.Property<string>("Query")
.HasColumnType("TEXT");
@@ -3380,6 +3388,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OwnedByChannelId");
b.ToTable("SmartCollection", (string)null);
});
@@ -19,6 +19,10 @@ public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollec
builder.HasIndex(mc => mc.Name)
.IsUnique();
// Cleanup + list-hiding of the system multi collection an auto-tune weighted channel owns (#425).
// Nullable: null = a normal user multi collection.
builder.HasIndex(mc => mc.OwnedByChannelId);
builder.HasMany(m => m.Collections)
.WithMany(m => m.MultiCollections)
.UsingEntity<MultiCollectionItem>(
@@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -16,5 +16,9 @@ public class SmartCollectionConfiguration : IEntityTypeConfiguration<SmartCollec
builder.HasIndex(sc => sc.Name)
.IsUnique();
// Cleanup + list-hiding of the per-source system smart collections an auto-tune weighted channel
// owns (#425). Nullable: null = a normal user smart collection.
builder.HasIndex(sc => sc.OwnedByChannelId);
}
}
@@ -41,4 +41,49 @@ public class AutoTuneAxisMapTests
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.TvGenre).ShouldBe(PlaybackOrder.Shuffle);
AutoTuneAxisMap.PlaybackOrderFor(AutoTuneAxis.MovieGenre).ShouldBe(PlaybackOrder.Shuffle);
}
// --- #425: per-source weighted distribution query authorship ---
[Test]
public void GenerateSourceQuery_Uses_ShowTitle_For_Tv_And_Id_For_Movies()
{
// TV episodes carry no parent-show id in the index; show_title is the only per-show discriminator.
AutoTuneAxisMap.GenerateSourceQuery(AutoTuneAxis.TvGenre, "The Office")
.ShouldBe("type:episode AND show_title:\"The Office\"");
AutoTuneAxisMap.GenerateSourceQuery(AutoTuneAxis.TvShow, "The Office")
.ShouldBe("type:episode AND show_title:\"The Office\"");
// A movie is the played item, so its own stable media-item id selects it exactly.
AutoTuneAxisMap.GenerateSourceQuery(AutoTuneAxis.MovieGenre, "1234")
.ShouldBe("type:movie AND id:1234");
}
[Test]
public void GenerateSourceQuery_Escapes_Tv_Discriminator()
{
AutoTuneAxisMap.GenerateSourceQuery(AutoTuneAxis.TvGenre, "Bob\"s \\Show")
.ShouldBe("type:episode AND show_title:\"Bob\\\"s \\\\Show\"");
}
[Test]
public void GenerateRemainderQuery_Returns_Base_When_Nothing_Subtracted()
{
AutoTuneAxisMap.GenerateRemainderQuery(AutoTuneAxis.TvGenre, "Comedy", [])
.ShouldBe("type:episode AND genre:\"Comedy\"");
}
[Test]
public void GenerateRemainderQuery_Subtracts_Tv_Sources_By_ShowTitle()
{
AutoTuneAxisMap.GenerateRemainderQuery(AutoTuneAxis.TvGenre, "Comedy", ["The Office", "Parks"])
.ShouldBe(
"(type:episode AND genre:\"Comedy\") AND NOT (show_title:\"The Office\" OR show_title:\"Parks\")");
}
[Test]
public void GenerateRemainderQuery_Subtracts_Movie_Sources_By_Id()
{
AutoTuneAxisMap.GenerateRemainderQuery(AutoTuneAxis.MovieGenre, "Action", ["12", "34"])
.ShouldBe("(type:movie AND genre:\"Action\") AND NOT (id:12 OR id:34)");
}
}
@@ -8,8 +8,12 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
@@ -239,6 +243,13 @@ public class CreateAutoTunedChannelsHandlerTests
result.Results[0].Status.ShouldBe(AutoTuneOutcomeStatus.Failed);
}
// These tests exercise the non-weighted path only (no Sources), so BuildWeightedPlan returns early
// before touching the db context / search targets — substitutes are sufficient.
private Task<AutoTuneResult> Handle(CreateAutoTunedChannels request) =>
new CreateAutoTunedChannelsHandler(_mediator).Handle(request, CancellationToken.None);
new CreateAutoTunedChannelsHandler(
_mediator,
Substitute.For<IDbContextFactory<TvContext>>(),
Substitute.For<ISearchTargets>(),
Substitute.For<ISmartCollectionCache>())
.Handle(request, CancellationToken.None);
}
@@ -0,0 +1,212 @@
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);
}
@@ -59,6 +59,44 @@ public class DeleteChannelHandlerTests : ChannelHandlerTestBase
fileSystem.File.Exists(cacheFile).ShouldBeFalse();
}
[Test]
public async Task Should_Delete_System_Owned_Weighted_AutoTune_Artifacts_But_Not_User_Collections()
{
await SeedChannel(1, "5");
await using (TvContext seed = Db.CreateContext())
{
// Artifacts owned by channel 1 (a weighted auto-tune channel's MultiCollection + member).
seed.SmartCollections.Add(
new ErsatzTV.Core.Domain.SmartCollection
{
Id = 10, Name = "at:owned:0", Query = "type:episode AND show_title:\"A\"", OwnedByChannelId = 1
});
seed.MultiCollections.Add(
new ErsatzTV.Core.Domain.MultiCollection { Id = 20, Name = "at-mc:owned", OwnedByChannelId = 1 });
// A user's own collections (and another channel's) must survive.
seed.SmartCollections.Add(
new ErsatzTV.Core.Domain.SmartCollection { Id = 11, Name = "user-sc", Query = "type:movie" });
seed.MultiCollections.Add(
new ErsatzTV.Core.Domain.MultiCollection { Id = 21, Name = "user-mc" });
seed.MultiCollections.Add(
new ErsatzTV.Core.Domain.MultiCollection { Id = 22, Name = "at-mc:other", OwnedByChannelId = 2 });
await seed.SaveChangesAsync();
}
Either<BaseError, Unit> result = await MakeHandler().Handle(new DeleteChannel(1), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
(await context.SmartCollections.AnyAsync(sc => sc.Id == 10)).ShouldBeFalse();
(await context.MultiCollections.AnyAsync(mc => mc.Id == 20)).ShouldBeFalse();
(await context.SmartCollections.AnyAsync(sc => sc.Id == 11)).ShouldBeTrue();
(await context.MultiCollections.AnyAsync(mc => mc.Id == 21)).ShouldBeTrue();
(await context.MultiCollections.AnyAsync(mc => mc.Id == 22)).ShouldBeTrue();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -0,0 +1,60 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Search;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Search;
// #425: the scheduling collection picker (search-smart/multi-collections) must NOT surface the
// system-owned weighted-auto-tune artifacts — selecting one into a user schedule would let a later
// channel delete cascade away that schedule item.
[TestFixture]
public class CollectionPickerHidesOwnedTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task SearchSmartCollections_Excludes_Owned()
{
await using (TvContext ctx = _db.CreateContext())
{
ctx.SmartCollections.Add(new SmartCollection { Name = "at:tok:0", Query = "q", OwnedByChannelId = 5 });
ctx.SmartCollections.Add(new SmartCollection { Name = "at:user:sc", Query = "q" });
await ctx.SaveChangesAsync();
}
var result = await new SearchSmartCollectionsHandler(_db.Factory)
.Handle(new SearchSmartCollections("at"), CancellationToken.None);
result.ShouldContain(vm => vm.Name == "at:user:sc");
result.ShouldNotContain(vm => vm.Name == "at:tok:0");
}
[Test]
public async Task SearchMultiCollections_Excludes_Owned()
{
await using (TvContext ctx = _db.CreateContext())
{
ctx.MultiCollections.Add(new MultiCollection { Name = "at-mc:tok", OwnedByChannelId = 5 });
ctx.MultiCollections.Add(new MultiCollection { Name = "at-mc:user" });
await ctx.SaveChangesAsync();
}
var result = await new SearchMultiCollectionsHandler(_db.Factory)
.Handle(new SearchMultiCollections("at"), CancellationToken.None);
result.ShouldContain(vm => vm.Name == "at-mc:user");
result.ShouldNotContain(vm => vm.Name == "at-mc:tok");
}
}
@@ -33,7 +33,8 @@ public record AutoTunedChannelRequest(
string Number,
int? TemplateId = null,
ArtworkContentTypeModel Logo = null,
CreateChannelFromLineupAdvancedOptionsRequest Advanced = null)
CreateChannelFromLineupAdvancedOptionsRequest Advanced = null,
List<AutoTuneSourceWeightRequest> Sources = null)
{
public AutoTuneChannelSelection ToCommand() =>
new(
@@ -43,5 +44,13 @@ public record AutoTunedChannelRequest(
Number,
TemplateId,
(Logo ?? ArtworkContentTypeModel.None).Sanitized(),
Advanced?.ToCommand());
Advanced?.ToCommand(),
Sources?.Select(s => s.ToCommand()).ToList());
}
// Per-source rotation weight + query correction (#425). SourceId is the show/movie id from the members
// list (GET /api/v1/channels/auto-tune/members); Weight defaults to 1 (fair-share); Excluded drops it.
public record AutoTuneSourceWeightRequest(int SourceId, int Weight = 1, bool Excluded = false)
{
public AutoTuneSourceWeight ToCommand() => new(SourceId, Weight, Excluded);
}
+30
View File
@@ -23053,6 +23053,15 @@
},
"advanced": {
"$ref": "#/components/schemas/CreateChannelFromLineupAdvancedOptionsRequest"
},
"sources": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/AutoTuneSourceWeightRequest"
}
}
}
},
@@ -23117,6 +23126,27 @@
}
}
},
"AutoTuneSourceWeightRequest": {
"required": [
"sourceId"
],
"type": "object",
"properties": {
"sourceId": {
"type": "integer",
"format": "int32"
},
"weight": {
"type": "integer",
"format": "int32",
"default": 1
},
"excluded": {
"type": "boolean",
"default": false
}
}
},
"BlockGroupResponseModel": {
"required": [
"id",
+10 -3
View File
@@ -344,9 +344,16 @@ fields: `templateId` (overrides the batch template), `advanced` (reuses the manu
`CreateChannelFromLineupAdvancedOptionsRequest` verbatim — same 24-field override set + `ToCommand()`), and
`logo` (an uploaded `{path, contentType}` image, `Sanitized()` at the request boundary per §4a). Omitting each
preserves PR1 behavior exactly (batch template, axis-derived playback order, on-the-fly fallback logo). This is
a second caller of the `from-lineup` advanced-options wire contract — do **not** mint a parallel DTO. Per-source
rotation weights are **not** here (deferred to #425; they need a MultiCollection redesign — see `docs/decisions.md`
2026-07-17 #385). Regenerated the OpenAPI trio (v1.json/v1.d.ts/endpoint-index) even though only schemas changed.
a second caller of the `from-lineup` advanced-options wire contract — do **not** mint a parallel DTO. Regenerated
the OpenAPI trio (v1.json/v1.d.ts/endpoint-index) even though only schemas changed.
**DTO expansion (#425, per-source rotation weights + query corrections)**: again no new endpoint — the same
`POST /api/v1/channels/auto-tune` request gained one more **optional** per-channel field, `sources:
[{sourceId, weight, excluded}]` (`AutoTuneSourceWeightRequest`; `sourceId` is a show/movie id from the members
list). Omitting it, or sending only fair-share weights with nothing excluded/added, keeps the single-SmartCollection
channel; otherwise the channel is built as a system-owned MultiCollection of per-source SmartCollections with
`WeightedShuffle` (see `docs/decisions.md` 2026-07-18 #425 for the materialization/remainder semantics). Only the
request schema changed, so the OpenAPI trio was regenerated (v1.json/v1.d.ts/endpoint-index).
**Endpoint inventory addition (#176, visual rule builder backend)**: one read-only `SearchController`
GET, standard credential (catalog-read tier — no `[RequiresAuthentication]`):
+50
View File
@@ -94,6 +94,7 @@ in-file entries.
- [2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)](#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164)
- [2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)](#2026-07-18--auto-tune-detailpanel-spa-reusable-slideover--shared-advanced-options-model-decorative-panes-dropped-to-match-the-backend-386)
- [2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)](#2026-07-18--smartcollection-rule-builder-compile-only-closed-subset-no-stored-ast-one-level-nesting-176)
- [2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)](#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425)
---
@@ -1714,3 +1715,52 @@ index actually supports.
for value inputs, relative-date operators, nesting deeper than one level, and inline adoption of
`RuleBuilder` by ChannelBuilder / Auto-Tune (it was built reusable for exactly that reuse — see
`spa-conventions.md` §12).
## 2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)
Per-source rotation weights (`3× Show A, 1× Show B`) and query corrections (exclude / add-untagged) for
an auto-tune channel are supplied **at bulk-create time** — an optional `sources: [{sourceId, weight,
excluded}]` list on each `AutoTunedChannelRequest` (the same DetailPanel-wizard surface #385 added its
per-channel `advanced`/`templateId`/`logo` to). There is **no** post-hoc `PUT .../weights`, no lazy
upgrade/downgrade, and no idempotent desired-state handler: the auto-tune DetailPanel (#383/#386) is a
create-wizard, and #425's Done-when is "create → built playout". (An earlier plan draft assumed a PUT on an
existing channel; the shipped flow is create-time, matching #385.)
**Structure — Option A (reuse #70), not a new fake-collection weight path.** When any source is customized
(a non-default weight, an exclusion, or an added out-of-axis id), the channel is backed by a system-owned
`MultiCollection` of per-source `SmartCollection`s, weights on `MultiCollectionSmartItem.Weight`, and its
single-item lineup points at the `MultiCollectionId` with `PlaybackOrder.WeightedShuffle` — the exact path
`WeightedShuffleCollectionEnumerator` already consumes (`GetMultiCollectionCollections` forwards the per-row
weight). Rejected: threading a weight map through `GroupIntoFakeCollections` (the fake-collection path a lone
SmartCollection takes, which hardcodes weight 1) — it derives its group keys at runtime, is shared with the
unrelated `FillWithGroupMode`, and would need a parallel weight-persistence home. When **no** source is
customized (all weights 1, nothing excluded/added) the channel keeps the #69 single-SmartCollection
fair-share shape — cheaper, and identical output for TV since the fake path already groups per show.
**Discriminators.** A source's member query is discriminator-only (membership is fixed at tune time): TV uses
`type:episode AND show_title:"X"` (episode docs carry no parent-show id in the index — `show_title` is the
only per-show field, the same one #69's TvShow axis already uses; a post-create rename empties the member
until re-tuned), and movies use the stable, rename-proof `type:movie AND id:{mediaItemId}` (a movie is the
played item). The remainder is `({base}) AND NOT ({d1} OR {d2} …)` over every materialized excluded
discriminator — a partition of the base set, so no item is counted twice or dropped. Exclude = omit the
member **and** keep it in the NOT-list (else its items leak back through the remainder); add-untagged = an
ordinary member whose id isn't in the base set (no genre clause, so it just airs).
**Materialization bound is axis-dependent — "weight N" means N× *each* other source.** TV materializes
**every** base show as its own member (un-weighted shows keep per-show fair-share; a single merged remainder
would regress them to item-proportional, so a 200-episode show would swamp a 20-episode one) plus one live
remainder at weight 1 for shows/episodes added after tune-in (empty at create → harmlessly skipped by
`WeightedShuffleCollectionEnumerator`'s `ActiveSources` filter). MovieGenre materializes **only** the touched
movies (weighted or added) and leaves every un-touched base movie in ONE remainder whose weight = its member
count — exactly equivalent to materializing each movie individually, because the fake path already pools
movies uniformly, without hundreds of rows. Cost note: a large TV genre materializes one SmartCollection per
show (dozenshundreds), each a cheap stored term query; a future optimization could cap/warn.
**Ownership + lifecycle.** The MultiCollection and its member/remainder SmartCollections carry a nullable
`OwnedByChannelId` (dual-provider migration `AddCollectionOwnedByChannelId`, indexed). Owned rows are hidden
from the user-facing collection lists and cascade-cleaned when the channel is deleted (the
`ProgramScheduleItem → MultiCollection` cascade removes the dangling flood item). Names embed a per-create
token (`at-mc:{token}`, `at:{token}:{n}`) because both names are unique `varchar(50)` and the channel id
isn't known until `CreateChannelFromLineup` runs; ownership is stamped immediately after. Non-weighted (#69)
channels set no ownership, so their pre-existing orphan-on-delete behavior is unchanged. The create is
non-atomic across the two handlers (mirrors #69) with best-effort rollback of the artifacts on channel-create
failure.
+1 -1
View File
@@ -79,7 +79,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; per-channel DetailPanel content-source members read via `GET /api/v1/channels/auto-tune/members`, #384; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385 per-source rotation weights deferred to #425) |
| **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; per-channel `templateId`/`advanced`/`logo` overrides accepted by `POST /api/v1/channels/auto-tune`, #385; per-source rotation weights + query corrections via an optional `sources: [{sourceId, weight, excluded}]` on that same request, #425 — a customized channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s with `PlaybackOrder.WeightedShuffle`, `OwnedByChannelId`-tagged so it's hidden from collection lists and cleaned up on channel delete) |
| **Channel health / `PlayoutCount`** (#72) | Whether a channel can play at all. `PlayoutCount` (channel's own playouts, **plus the mirror source's** when `PlayoutSource is Mirror` — computed by `Mapper.GetPlayoutsCount`) rides on both `ChannelResponseModel` (list) and `ChannelDetailResponseModel`; `0` ⇒ the channel can never play, rendered as a "No playout" badge + a matching **No playout** filter on the channels list (both name only the one fault the API can prove — a broader "Problems" label would read as a false all-clear to a user whose *other* fault classes below are uncomputed). It is a **raw fact, not a status enum** — see `decisions.md` 2026-07-17. Distinct from `/api/v1/channels/state`'s `OnAir`, which is runtime liveness ("someone is streaming right now"), not "would play if tuned". Empty-schedule, broken-source and auto-tuned-vs-user origin are deliberately **not** computed (see that decision entry for why each is unsafe today). | `Channel.Playouts` | `/app/channels` (read-only signal) |
| **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()` | — |
+6
View File
@@ -74,6 +74,7 @@ export interface components {
"templateId"?: null | number;
"logo"?: components["schemas"]["ArtworkContentTypeModel"];
"advanced"?: components["schemas"]["CreateChannelFromLineupAdvancedOptionsRequest"];
"sources"?: null | Array<components["schemas"]["AutoTuneSourceWeightRequest"]>;
};
"AutoTuneProposalResponseModel": {
"axis": string;
@@ -88,6 +89,11 @@ export interface components {
"createdCount": number;
"skippedCount": number;
"failedCount": number;
};
"AutoTuneSourceWeightRequest": {
"sourceId": number;
"weight"?: number;
"excluded"?: boolean;
};
"BlockGroupResponseModel": {
"id": number;