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,89 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Франшиза: упорядоченный набор шоу, который играется как одно целое. «Терминатор» — это три
/// полнометражки в порядке 1→2→3.
///
/// Живёт в библиотеке, а не в планировщике, потому что «одно произведение из трёх частей» — факт
/// о контенте, а не о канале. Планировщику коллекция видна как обычная последовательность
/// воспроизводимых единиц, ровно как сериал со своими сериями: за счёт этого «три части подряд
/// в один вечер» и «три дня по одному фильму» получаются одним и тем же слотом с разным размером
/// блока, без спецлогики для коллекций.
/// </summary>
public class Collection
{
private readonly List<CollectionItem> _items = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string? Description { get; private set; }
/// <summary>Постер коллекции — ссылка на запись общего реестра изображений или null.</summary>
public Guid? PosterImageId { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Позиции коллекции (backing-field для EF); порядок показа — по <see cref="CollectionItem.Position"/>.</summary>
public IReadOnlyList<CollectionItem> Items => _items;
private Collection() { }
public static Collection 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;
}
/// <summary>Привязать/снять постер (ссылка на реестр изображений; сама картинка остаётся в галерее).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
public bool HasShow(Guid showId) => _items.Any(i => i.ShowId == showId);
/// <summary>Добавляет шоу в конец. Повторное добавление игнорируется — возвращает null.</summary>
public CollectionItem? AddShow(Guid showId)
{
if (HasShow(showId))
return null;
var nextPosition = _items.Count == 0 ? 0 : _items.Max(i => i.Position) + 1;
var item = CollectionItem.Create(Id, showId, nextPosition);
_items.Add(item);
return item;
}
public bool RemoveShow(Guid showId)
{
var item = _items.FirstOrDefault(i => i.ShowId == showId);
if (item is null)
return false;
_items.Remove(item);
return true;
}
/// <summary>
/// Переставляет позиции в порядке переданных идентификаторов. Не упомянутые остаются после них,
/// сохраняя относительный порядок, а неизвестные игнорируются — так частичный ввод (перетащили
/// один элемент) не теряет остальные.
/// </summary>
public void Reorder(IEnumerable<Guid> showIdsInOrder)
{
var requested = showIdsInOrder.Distinct().Where(HasShow).ToList();
var rest = _items
.Where(i => !requested.Contains(i.ShowId))
.OrderBy(i => i.Position)
.Select(i => i.ShowId);
var position = 0;
foreach (var showId in requested.Concat(rest))
_items.First(i => i.ShowId == showId).SetPosition(position++);
}
}
@@ -0,0 +1,25 @@
namespace TeleWave.Domain.Library;
/// <summary>Шоу в составе коллекции. Порядок показа — по <see cref="Position"/>.</summary>
public class CollectionItem
{
public Guid Id { get; private set; }
public Guid CollectionId { get; private set; }
public Guid ShowId { get; private set; }
/// <summary>Порядковый номер внутри коллекции (может иметь разрывы после удалений).</summary>
public int Position { get; private set; }
private CollectionItem() { }
internal static CollectionItem Create(Guid collectionId, Guid showId, int position) =>
new()
{
Id = Guid.NewGuid(),
CollectionId = collectionId,
ShowId = showId,
Position = position,
};
internal void SetPosition(int position) => Position = position;
}
@@ -0,0 +1,70 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Жанр из общего справочника. Справочник, а не свободные строки: по нему собираются группы контента
/// для планировщика («один случайный боевик по пятницам»), а жанры от внешних провайдеров
/// (TMDb/OMDb) приводятся к нему через <see cref="Aliases"/>.
///
/// Свои произвольные метки («ретро», «новогоднее») сюда не заводятся — их роль выполняют группы
/// с ручным составом.
/// </summary>
public class Genre
{
private readonly List<GenreAlias> _aliases = new();
public Guid Id { get; private set; }
/// <summary>Отображаемое название («Боевик»).</summary>
public string Name { get; private set; } = string.Empty;
/// <summary>Стабильный ключ («action») — по нему жанр находится при повторном сидинге.</summary>
public string Slug { get; private set; } = string.Empty;
/// <summary>Порядок в списках UI (меньше — выше).</summary>
public int SortOrder { get; private set; }
/// <summary>Признак жанра из стартового сида — такой нельзя удалить, только переименовать.</summary>
public bool IsSystem { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Варианты написания для сопоставления с внешними источниками (backing-field для EF).</summary>
public IReadOnlyList<GenreAlias> Aliases => _aliases;
private Genre() { }
public static Genre Create(string name, string slug, int sortOrder = 0, bool isSystem = false) =>
new()
{
Id = Guid.NewGuid(),
Name = name.Trim(),
Slug = GenreAlias.Normalize(slug),
SortOrder = sortOrder,
IsSystem = isSystem,
CreatedAt = DateTimeOffset.UtcNow,
};
public void Rename(string name) => Name = name.Trim();
public void SetSortOrder(int sortOrder) => SortOrder = sortOrder;
/// <summary>Добавляет вариант написания, если такого ещё нет. Пустые значения игнорируются.</summary>
public void AddAlias(string alias)
{
var normalized = GenreAlias.Normalize(alias);
if (normalized.Length == 0 || _aliases.Any(a => a.Value == normalized))
return;
_aliases.Add(GenreAlias.Create(Id, normalized));
}
/// <summary>
/// Полностью заменяет набор вариантов написания. Снятые псевдонимы из сида вернутся при следующем
/// старте приложения — сид дописывает недостающие, но только если их не занял другой жанр.
/// </summary>
public void ReplaceAliases(IEnumerable<string> aliases)
{
_aliases.Clear();
foreach (var alias in aliases)
AddAlias(alias);
}
}
@@ -0,0 +1,46 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Вариант написания жанра для сопоставления с внешними источниками. Хранит уже нормализованное
/// значение (см. <see cref="Normalize"/>), поэтому поиск — это точное сравнение, без LIKE и без
/// приведения регистра на стороне БД.
///
/// Формы значения: числовой идентификатор провайдера (<c>tmdb:28</c>) либо имя жанра на любом языке
/// (<c>action</c>, <c>боевик</c>, <c>sci-fi</c>). Один жанр справочника собирает под собой сколько
/// угодно вариантов — так «Action» и «Action &amp; Adventure» из TMDb сводятся к одному «Боевику».
/// </summary>
public class GenreAlias
{
public Guid Id { get; private set; }
public Guid GenreId { get; private set; }
/// <summary>Нормализованное значение — уникально по всему справочнику.</summary>
public string Value { get; private set; } = string.Empty;
private GenreAlias() { }
internal static GenreAlias Create(Guid genreId, string value) =>
new()
{
Id = Guid.NewGuid(),
GenreId = genreId,
Value = Normalize(value),
};
/// <summary>Приводит написание к каноничному виду: нижний регистр, схлопнутые пробелы, без краёв.</summary>
public static string Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var trimmed = value.Trim().ToLowerInvariant();
return string.Join(
' ',
trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
);
}
/// <summary>Псевдоним по идентификатору жанра во внешнем источнике (<c>tmdb:28</c>).</summary>
public static string ProviderKey(string provider, string externalId) =>
Normalize($"{provider}:{externalId}");
}
+25 -1
View File
@@ -8,6 +8,7 @@ namespace TeleWave.Domain.Library;
public class Show
{
private readonly List<ShowEpisode> _episodes = new();
private readonly List<ShowGenre> _genres = new();
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
@@ -40,6 +41,9 @@ public class Show
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { }
public static Show Create(
@@ -60,9 +64,29 @@ public class Show
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Задать категорию аудитории (обычное/детское/взрослое).</summary>
/// <summary>Задать категорию аудитории.</summary>
public void SetAudience(ShowAudience audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
/// <summary>
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
/// так шоу с жанрами никогда не остаётся без основного.
/// </summary>
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
{
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
_genres.Clear();
if (ids.Count == 0)
return;
var primary = primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
foreach (var id in ids)
_genres.Add(ShowGenre.Create(Id, id, id == primary));
}
public void Rename(string name, string? description)
{
Name = name;
@@ -1,14 +1,27 @@
namespace TeleWave.Domain.Library;
/// <summary>Категория аудитории шоу (пригодится для фильтров/разграничения показа).</summary>
/// <summary>
/// Возрастная категория шоу. Значения упорядочены по возрастанию строгости — на этом порядке строятся
/// правила планировщика вида «до 23:00 не строже подросткового». Новые категории можно вставлять
/// только с сохранением монотонности и с миграцией данных.
///
/// Это не гейт для зрителя: что показывает канал, то и смотрится. Категория нужна планировщику,
/// чтобы не поставить взрослое в детский эфир.
/// </summary>
public enum ShowAudience
{
/// <summary>Обычное — без ограничений (по умолчанию).</summary>
General,
/// <summary>Детское.</summary>
Kids,
Kids = 0,
/// <summary>Семейное.</summary>
Family = 1,
/// <summary>Подростковое.</summary>
Teen = 2,
/// <summary>Общее — без ограничений (по умолчанию).</summary>
General = 3,
/// <summary>Взрослое.</summary>
Adult,
Adult = 4,
}
@@ -0,0 +1,23 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Жанр, проставленный шоу. Ровно один из жанров шоу — основной (<see cref="IsPrimary"/>): он
/// показывается в списках, остальные участвуют в фильтрах наравне с ним. Инвариант «основной ровно
/// один» держит агрегат <see cref="Show.SetGenres"/>.
/// </summary>
public class ShowGenre
{
public Guid ShowId { get; private set; }
public Guid GenreId { get; private set; }
public bool IsPrimary { get; private set; }
private ShowGenre() { }
internal static ShowGenre Create(Guid showId, Guid genreId, bool isPrimary) =>
new()
{
ShowId = showId,
GenreId = genreId,
IsPrimary = isPrimary,
};
}
@@ -4,8 +4,18 @@ namespace TeleWave.Domain.Library;
public enum ShowKind
{
/// <summary>Сериал: упорядоченный список серий, идут по порядку.</summary>
Series,
Series = 0,
/// <summary>Разовый выпуск/полнометражка: ровно одна «серия».</summary>
Single,
Single = 1,
/// <summary>
/// Ролик-врезка: реклама, промо, джингл. Технически то же шоу с одной «серией», поэтому попадает
/// в группы и коллекции наравне с контентом — так рекламный блок собирается обычной коллекцией,
/// а остывание («не крутить один ролик дважды подряд») достаётся ему бесплатно.
///
/// В общей библиотеке не показывается и метаданными не обогащается: у ролика нет ни года,
/// ни постера, а сотня роликов на экране шоу только мешала бы.
/// </summary>
Interstitial = 2,
}