Files
ersatztv/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs
T
timothyandtimothy 6462c36983
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
fix(310): strip UTF-8 BOM from the legacy .cs files #269 touched (#449)
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:15:35 +00:00

100 lines
4.2 KiB
C#

using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _searchChannel;
public RemoveItemsFromCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel,
ChannelWriter<ISearchIndexBackgroundServiceRequest> searchChannel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
_searchChannel = searchChannel;
}
public async Task<Either<BaseError, Unit>> Handle(
RemoveItemsFromCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await maybeCollection.Match(
Some: collection => ApplyRemoveItemsRequest(dbContext, request, collection, cancellationToken),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Collection {request.MediaCollectionId} does not exist.")));
}
private async Task<Either<BaseError, Unit>> ApplyRemoveItemsRequest(
TvContext dbContext,
RemoveItemsFromCollection request,
Collection collection,
CancellationToken cancellationToken)
{
List<int> requestedIds = request.MediaItemIds.Distinct().ToList();
var itemsToRemove = collection.MediaItems
.Filter(m => requestedIds.Contains(m.Id))
.ToList();
if (itemsToRemove.Count != requestedIds.Count)
{
return new NotFoundError("Collection item does not exist.");
}
// No-op when nothing is actually removed: don't rotate the ETag or fan out rebuilds — #269.
if (itemsToRemove.Count == 0)
{
return Unit.Default;
}
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
// Rotate the collection ETag so an open custom-order editor's If-Match invalidates (#269);
// force-write past a concurrent replace-all Version bump — this remove takes no If-Match, so a
// benign race must not 500 (#253/#269 §7a).
collection.Version++;
await dbContext.SaveChangesForcingVersion(cancellationToken);
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await _searchChannel.WriteAsync(
new ReindexMediaItems(itemsToRemove.Select(mi => mi.Id).ToArray()),
CancellationToken.None);
// refresh all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Refresh), CancellationToken.None);
}
return Unit.Default;
}
private static Task<Option<Collection>> CollectionMustExist(
TvContext dbContext,
RemoveItemsFromCollection request,
CancellationToken cancellationToken) =>
dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.MediaCollectionId, cancellationToken)
.Map(identity);
}