Auto-tune channels can now carry per-content-source rotation weights (weighted
round-robin, e.g. 3x Show A / 1x Show B) and query corrections (exclude /
add-untagged), supplied at bulk-create time via an optional
`sources: [{sourceId, weight, excluded}]` on each AutoTunedChannelRequest.
Design (Option A, reuse #70): when a source is customized the channel is backed
by a system-owned MultiCollection of per-source SmartCollections carrying the
weights, with PlaybackOrder.WeightedShuffle -- the exact path
WeightedShuffleCollectionEnumerator already consumes. All-default weights keep
the #69 single-SmartCollection fair-share shape.
- Discriminators: TV -> live show_title:"X" (episodes carry no parent-show id in
the index); movies -> stable id:{mediaItemId}.
- Materialization is axis-dependent: TV materializes every base show individually
(un-weighted shows keep per-show fair-share) + a live remainder at weight 1;
MovieGenre materializes only touched movies + one count-weighted remainder.
- Remainder = (base) AND NOT (materialized union excluded) -- a partition.
- New nullable OwnedByChannelId on SmartCollection + MultiCollection
(dual-provider migration); owned rows are hidden from the collection lists and
cascade-cleaned on channel delete.
Tests: AutoTuneAxisMap query/partition units; DB-backed weighted-path handler
tests (TV materialize-all, movie count-remainder, exclusion, no-customization
fallback); delete-cleanup. Docs: decisions.md, domain-model.md, api-conventions.md;
OpenAPI trio regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
85 lines
3.9 KiB
C#
85 lines
3.9 KiB
C#
using System.IO.Abstractions;
|
|
using System.Threading.Channels;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Errors;
|
|
using ErsatzTV.Core.Interfaces.Search;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Extensions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Channel = ErsatzTV.Core.Domain.Channel;
|
|
|
|
namespace ErsatzTV.Application.Channels;
|
|
|
|
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
|
|
{
|
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
|
private readonly IFileSystem _fileSystem;
|
|
private readonly ISearchTargets _searchTargets;
|
|
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
|
|
|
public DeleteChannelHandler(
|
|
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
|
IDbContextFactory<TvContext> dbContextFactory,
|
|
IFileSystem fileSystem,
|
|
ISearchTargets searchTargets)
|
|
{
|
|
_workerChannel = workerChannel;
|
|
_dbContextFactory = dbContextFactory;
|
|
_fileSystem = fileSystem;
|
|
_searchTargets = searchTargets;
|
|
}
|
|
|
|
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
|
|
{
|
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
Option<Channel> maybeChannel = await dbContext.Channels
|
|
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
|
|
|
|
return await maybeChannel.Match(
|
|
Some: async channel =>
|
|
{
|
|
await DoDeletion(dbContext, channel, cancellationToken);
|
|
return Right<BaseError, Unit>(Unit.Default);
|
|
},
|
|
None: () => Task.FromResult(
|
|
Left<BaseError, Unit>(new NotFoundError($"Channel {request.ChannelId} does not exist."))));
|
|
}
|
|
|
|
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
|
{
|
|
// Delete the guide cache file through the filesystem abstraction (so it's observable under a
|
|
// MockFileSystem) and BEFORE the commit: deleting after commit orphans {number}.xml if the
|
|
// process crashes in between (nothing reaps it, and GetChannelGuideHandler serves everything
|
|
// in the cache folder). The guide xml is regenerable on demand, so losing it pre-commit is safe (#254).
|
|
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
|
if (_fileSystem.File.Exists(cacheFile))
|
|
{
|
|
_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
|
|
// CancellationToken.None so a late request cancellation can't abort it after the delete committed (#254)
|
|
await _workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
|
|
|
|
return Unit.Default;
|
|
}
|
|
}
|