feat(425): per-source rotation weights + query corrections for auto-tune channels #439
@@ -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());
|
||||
}
|
||||
|
||||
+2
-1
@@ -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; }
|
||||
}
|
||||
|
||||
Generated
+7260
File diff suppressed because it is too large
Load Diff
+56
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Generated
+7085
File diff suppressed because it is too large
Load Diff
+56
@@ -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>(
|
||||
|
||||
+5
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]`):
|
||||
|
||||
@@ -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 (dozens–hundreds), 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.
|
||||
|
||||
@@ -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()` | — |
|
||||
|
||||
Vendored
+6
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user