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.
This commit is contained in:
@@ -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 & 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}");
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user