namespace TeleWave.Domain.Programming; /// /// Группа контента — «что может попасть в эфир». Слот планировщика ссылается на группу, стратегия /// выбирает из неё элемент. Группы общие для всех каналов и переиспользуются. /// /// Откуда берётся состав, решает : /// /// • — состав это явный список (), а /// лишь помогает его наполнить: «применить фильтр» добавляет найденное /// в список, дальше он правится руками. /// /// • — состав вычисляет правило в момент обращения, а список хранит /// только ручные поправки к нему: закреплённое () и исключённое /// (). Само правило домен по-прежнему не интерпретирует — /// вычисление живёт в Application. /// /// Уже материализованное расписание в обоих режимах не переписывается: изменение состава проявится /// на следующем прогоне для ещё не сгенерированных суток. Иначе добавление одного шоу в библиотеку /// перетасовывало бы будущий эфир всех каналов. /// public class Group { private readonly List _items = []; public Guid Id { get; private set; } public string Name { get; private set; } = string.Empty; public string? Description { get; private set; } /// Правило набора (JSON) или null. Домен его не интерпретирует — схема живёт в Application. public string? FilterJson { get; private set; } /// Откуда берётся состав: явный список либо вычисление правилом. public GroupMode Mode { get; private set; } // ── Кэш статистики: считается при правке состава и фоново, нужен UI («342 позиции · 118 ч») ── /// Число позиций в группе. public int ItemCount { get; private set; } /// Число единиц воспроизведения: у сериала и коллекции их больше одной. public int UnitCount { get; private set; } /// Суммарная длительность готовых единиц. public TimeSpan TotalDuration { get; private set; } public DateTimeOffset? StatsComputedAt { get; private set; } public DateTimeOffset CreatedAt { get; private set; } /// Позиции группы (backing-field для EF); порядок — по . public IReadOnlyList Items => _items; private Group() { } public static Group Create(string name, string? description = null) => new() { Id = Guid.NewGuid(), Name = name.Trim(), Description = description, CreatedAt = DateTimeOffset.UtcNow, }; public void Rename(string name, string? description) { Name = name.Trim(); Description = description; } /// Сохранить правило набора (null — снять). Состав при этом не меняется. public void SetFilter(string? filterJson) => FilterJson = string.IsNullOrWhiteSpace(filterJson) ? null : filterJson; /// /// Сменить режим. Переключения не должны менять того, что уже идёт в эфире, поэтому состав /// переносится, а не теряется: при переходе в динамический список позиций становится /// закреплённым (правило добавит к нему остальное), при обратном переходе закреплённое /// становится обычными позициями, а исключения снимаются — исключать больше нечего. /// /// Вычисленный правилом состав при переходе в статический режим сюда не переносится: домен /// правило не интерпретирует. Материализует его вызывающая сторона до смены режима. /// /// /// Меняет режим набора. Список позиций при этом **сбрасывается**: в двух режимах он означает /// разное, и переносить его как есть — значит соврать про то, чем группа стала. /// /// Прежний состав, оставленный закреплённым, отменял бы саму суть перехода: правило считало бы /// состав заново, но старые позиции оставались бы в нём навсегда, и «по правилу» на деле /// означало бы «по правилу плюс всё, что было». Сузить набор правилом стало бы невозможно. /// /// Обратный переход наполняет список вычисленным составом — это делает прикладной слой: правило /// живёт там, домен его не интерпретирует. /// public void SetMode(GroupMode mode) { if (Mode == mode) return; Mode = mode; _items.Clear(); } /// Записать пересчитанную статистику. public void UpdateStats(int itemCount, int unitCount, TimeSpan totalDuration, DateTimeOffset at) { ItemCount = itemCount; UnitCount = unitCount; TotalDuration = totalDuration; StatsComputedAt = at; } public bool Contains(GroupElementKind kind, Guid elementId) => _items.Any(i => i.ElementKind == kind && i.ElementId == elementId); /// /// Добавляет элемент в конец. Повторное добавление игнорируется — возвращает null. Роль по /// умолчанию зависит от режима: в динамической группе ручное добавление означает «закрепить», /// иначе правило вымело бы позицию на первом же обращении. /// public GroupItem? AddElement(GroupElementKind kind, Guid elementId, GroupItemRole? role = null) { if (Contains(kind, elementId)) return null; var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1; var item = GroupItem.Create(Id, kind, elementId, nextPosition, role ?? DefaultRole); _items.Add(item); return item; } private GroupItemRole DefaultRole => Mode == GroupMode.Dynamic ? GroupItemRole.Pinned : GroupItemRole.Member; /// /// Исключить элемент из динамической группы. Закрепление, если оно было, снимается: держать /// на один элемент обе противоположные поправки нельзя. Возвращает false, если группа /// статическая — там состав правится списком, а исключать из списка нечего. /// public bool Exclude(GroupElementKind kind, Guid elementId) { if (Mode != GroupMode.Dynamic) return false; var existing = _items.FirstOrDefault(i => i.ElementKind == kind && i.ElementId == elementId ); if (existing is not null) { existing.SetRole(GroupItemRole.Excluded); return true; } var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1; _items.Add(GroupItem.Create(Id, kind, elementId, nextPosition, GroupItemRole.Excluded)); return true; } /// Снять исключение — элемент вернётся в состав, если его находит правило. public bool RemoveExclusion(GroupElementKind kind, Guid elementId) => _items.RemoveAll(i => i.Role == GroupItemRole.Excluded && i.ElementKind == kind && i.ElementId == elementId ) > 0; public bool RemoveItem(Guid itemId) { var item = _items.FirstOrDefault(i => i.Id == itemId); if (item is null) return false; _items.Remove(item); return true; } /// Убирает все позиции, ссылающиеся на элемент. Вызывается при удалении шоу/коллекции /// из библиотеки — внешнего ключа на полиморфную ссылку нет. Возвращает число снятых позиций. public int RemoveElement(GroupElementKind kind, Guid elementId) => _items.RemoveAll(i => i.ElementKind == kind && i.ElementId == elementId); public bool SetWeight(Guid itemId, int weight) { var item = _items.FirstOrDefault(i => i.Id == itemId); if (item is null) return false; item.SetWeight(weight); return true; } /// /// Переставляет позиции в порядке переданных идентификаторов. Не упомянутые остаются после них, /// сохраняя относительный порядок, а неизвестные игнорируются — так перетаскивание одного /// элемента не теряет остальные. /// public void Reorder(IEnumerable itemIdsInOrder) { var known = _items.Select(i => i.Id).ToHashSet(); var requested = itemIdsInOrder.Distinct().Where(known.Contains).ToList(); var rest = _items .Where(i => !requested.Contains(i.Id)) .OrderBy(i => i.Position) .Select(i => i.Id); var position = 0; foreach (var itemId in requested.Concat(rest)) _items.First(i => i.Id == itemId).SetPosition(position++); } }