Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m34s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16m19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 19m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent review found a blocker + two mediums: - BLOCKER: SearchSmartCollections/SearchMultiCollections (the scheduling collection picker) didn't filter OwnedByChannelId, so a user could select a system-owned auto-tune artifact into their own schedule item — a later channel delete would then cascade-delete that schedule item (ProgramScheduleItem-> MultiCollection is OnDelete(Cascade)). Both handlers now exclude owned rows; regression test added (CollectionPickerHidesOwnedTests). - MEDIUM: TryStampOwnership now runs on CancellationToken.None (post-commit convention #254) and stamps the MC + member SmartCollections in one transaction, so a late cancel or mid-way failure can't leave a permanent orphan / list leak. - Documented the movie-remainder weight clamp bound (>1000 un-touched movies can't express the exact ratio — the 1..1000 weight-column bound from #70). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
520 lines
22 KiB
C#
520 lines
22 KiB
C#
using ErsatzTV.Application.Artworks;
|
||
using ErsatzTV.Application.MediaCollections;
|
||
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,
|
||
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)
|
||
{
|
||
string group = string.IsNullOrWhiteSpace(request.Group) ? DefaultGroup : request.Group.Trim();
|
||
var outcomes = new List<AutoTuneChannelOutcome>();
|
||
|
||
foreach (AutoTuneChannelSelection selection in request.Channels ?? [])
|
||
{
|
||
outcomes.Add(await CreateOne(request.TemplateId, group, selection, cancellationToken));
|
||
}
|
||
|
||
return new AutoTuneResult(outcomes);
|
||
}
|
||
|
||
private async Task<AutoTuneChannelOutcome> CreateOne(
|
||
int templateId,
|
||
string group,
|
||
AutoTuneChannelSelection selection,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
string name = (selection.Name ?? string.Empty).Trim();
|
||
if (name.Length is 0 or > 50)
|
||
{
|
||
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
|
||
// unless the DetailPanel set an explicit per-channel override. Any other Advanced field the caller
|
||
// set is layered on top of the template by CreateChannelFromLineup's `advanced.X ?? template.X`
|
||
// stamp-at-create contract, so we only have to fill in the axis-derived PlaybackOrder default here.
|
||
PlaybackOrder axisOrder = AutoTuneAxisMap.PlaybackOrderFor(selection.Axis);
|
||
CreateChannelFromLineupAdvancedOptions advanced =
|
||
(selection.Advanced ?? new CreateChannelFromLineupAdvancedOptions()) with
|
||
{
|
||
PlaybackOrder = selection.Advanced?.PlaybackOrder ?? axisOrder
|
||
};
|
||
|
||
// 1. Create the smart collection that drives this channel.
|
||
Either<BaseError, SmartCollectionViewModel> scResult =
|
||
await mediator.Send(new CreateSmartCollection(query, name), cancellationToken);
|
||
|
||
SmartCollectionViewModel smartCollection = null;
|
||
foreach (BaseError error in scResult.LeftToSeq())
|
||
{
|
||
return new AutoTuneChannelOutcome(
|
||
name, AutoTuneOutcomeStatus.Failed, null, $"Smart collection: {error.Value}");
|
||
}
|
||
|
||
foreach (SmartCollectionViewModel vm in scResult.RightToSeq())
|
||
{
|
||
smartCollection = vm;
|
||
}
|
||
|
||
// 2. Create the channel from a single-item lineup referencing the smart collection.
|
||
var command = new CreateChannelFromLineup(
|
||
name,
|
||
selection.Number,
|
||
group,
|
||
string.Empty,
|
||
logo,
|
||
IsEnabled: true,
|
||
ShowInEpg: true,
|
||
effectiveTemplateId,
|
||
advanced,
|
||
[
|
||
new CreateChannelFromLineupItem(
|
||
LibraryBrowseMediaType.SmartCollection,
|
||
CollectionType.SmartCollection,
|
||
CollectionId: null,
|
||
MultiCollectionId: null,
|
||
SmartCollectionId: smartCollection.Id,
|
||
RerunCollectionId: null,
|
||
MediaItemId: null,
|
||
PlaylistId: null)
|
||
]);
|
||
|
||
Either<BaseError, CreateChannelFromLineupResponseModel> channelResult =
|
||
await mediator.Send(command, cancellationToken);
|
||
|
||
foreach (BaseError error in channelResult.LeftToSeq())
|
||
{
|
||
// Roll back the smart collection we just created so a retry of this
|
||
// axis/value doesn't fail on SmartCollection-name uniqueness. Best-effort;
|
||
// the primary outcome below is still Skipped/Failed regardless of the delete result.
|
||
// Swallow any exception (not just an Either.Left) so a transient infra failure
|
||
// during rollback never aborts this channel's outcome or the batch; the
|
||
// orphaned SmartCollection is an acceptable degraded outcome.
|
||
try
|
||
{
|
||
await mediator.Send(new DeleteSmartCollection(smartCollection.Id), cancellationToken);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
// intentionally ignored; see comment above
|
||
}
|
||
|
||
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);
|
||
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
|
||
}
|
||
}
|
||
}
|