Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -0,0 +1,11 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
/// <summary>
/// Подбирает позиции по правилу набора. <paramref name="Filter"/> = null — берём правило, сохранённое
/// у группы; передача фильтра явно нужна редактору, где его крутят до сохранения.
/// </summary>
public sealed record FindGroupCandidatesQuery(Guid GroupId, GroupFilter? Filter = null)
: IQuery<Result<IReadOnlyList<GroupCandidateDto>>>;
@@ -0,0 +1,139 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Groups.FindGroupCandidates;
public sealed class FindGroupCandidatesQueryHandler(
IAppDbContext dbContext,
GroupElementResolver resolver
) : IQueryHandler<FindGroupCandidatesQuery, Result<IReadOnlyList<GroupCandidateDto>>>
{
public async Task<Result<IReadOnlyList<GroupCandidateDto>>> Handle(
FindGroupCandidatesQuery query,
CancellationToken cancellationToken
)
{
var group = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == query.GroupId, cancellationToken);
if (group is null)
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.NotFound);
var filter = query.Filter ?? GroupFilter.FromJson(group.FilterJson);
if (filter is null)
return Result.Failure<IReadOnlyList<GroupCandidateDto>>(GroupErrors.FilterNotSet);
var wantsShows =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Show);
var wantsCollections =
filter.ElementKinds is null or { Count: 0 }
|| filter.ElementKinds.Contains(GroupElementKind.Collection);
var matchingShowIds = await MatchShowIdsAsync(filter, cancellationToken);
var elements = new List<(GroupElementKind Kind, Guid Id)>();
if (wantsShows)
elements.AddRange(matchingShowIds.Select(id => (GroupElementKind.Show, id)));
if (wantsCollections)
{
// Коллекция подходит, только если подходят все её части: иначе во «взрослую ночь» через
// франшизу просочилось бы детское, а в «боевики» — комедия из той же серии фильмов.
var collections = await dbContext
.Collections.AsNoTracking()
.Select(c => new { c.Id, ShowIds = c.Items.Select(i => i.ShowId).ToList() })
.ToListAsync(cancellationToken);
var matching = matchingShowIds.ToHashSet();
elements.AddRange(
collections
.Where(c => c.ShowIds.Count > 0 && c.ShowIds.All(matching.Contains))
.Select(c => (GroupElementKind.Collection, c.Id))
);
}
var info = await resolver.ResolveAsync(elements, cancellationToken);
var inGroup = group
.Items.Select(i => (i.ElementKind, i.ElementId))
.ToHashSet();
var result = new List<GroupCandidateDto>();
foreach (var (kind, id) in elements)
{
if (!info.TryGetValue((kind, id), out var element))
continue;
if (!MatchesUnitDuration(filter, element))
continue;
result.Add(
new GroupCandidateDto(
kind,
id,
element.Name,
element.UnitCount,
element.ShowKind,
element.Audience,
element.Year,
element.PosterImageId,
inGroup.Contains((kind, id))
)
);
}
return Result.Success<IReadOnlyList<GroupCandidateDto>>(
result.OrderBy(c => c.ElementName, StringComparer.CurrentCultureIgnoreCase).ToList()
);
}
private async Task<List<Guid>> MatchShowIdsAsync(
GroupFilter filter,
CancellationToken cancellationToken
)
{
var shows = dbContext.Shows.AsNoTracking();
if (filter.ShowKinds is { Count: > 0 } kinds)
shows = shows.Where(s => kinds.Contains(s.Kind));
// Категории упорядочены по возрастанию строгости, поэтому «не строже» — обычное сравнение.
if (filter.MaxAudience is { } maxAudience)
shows = shows.Where(s => s.Audience <= maxAudience);
if (filter.YearMin is { } yearMin)
shows = shows.Where(s => s.Year != null && s.Year >= yearMin);
if (filter.YearMax is { } yearMax)
shows = shows.Where(s => s.Year != null && s.Year <= yearMax);
// Жанры — «любой из»: шоу редко подходит под все перечисленные сразу.
if (filter.GenreIds is { Count: > 0 } genreIds)
shows = shows.Where(s => s.Genres.Any(g => genreIds.Contains(g.GenreId)));
return await shows.Select(s => s.Id).ToListAsync(cancellationToken);
}
/// <summary>
/// Длительность проверяется по средней единице, а не по сумме: у сериала в фильтре «серии
/// по 20–25 минут» осмысленна именно длина серии. Элементы без готовых ассетов длительности
/// не имеют — их этот фильтр не отбрасывает, иначе из группы выпадало бы всё, что ещё
/// обрабатывается.
/// </summary>
private static bool MatchesUnitDuration(GroupFilter filter, GroupElementInfo element)
{
if (filter.UnitMinutesMin is null && filter.UnitMinutesMax is null)
return true;
if (element.UnitCount == 0 || element.TotalDuration <= TimeSpan.Zero)
return true;
var averageMinutes = element.TotalDuration.TotalMinutes / element.UnitCount;
if (filter.UnitMinutesMin is { } min && averageMinutes < min)
return false;
if (filter.UnitMinutesMax is { } max && averageMinutes > max)
return false;
return true;
}
}