namespace TeleWave.Domain.Library; /// /// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка. /// Серии идут строго в порядке ; где остановился показ — знает /// состояние слота планировщика (Programming/SlotState), а не само шоу: одно шоу играет /// на нескольких каналах и в нескольких слотах, и курсор у каждого свой. /// public class Show { private readonly List _episodes = []; private readonly List _genres = []; public Guid Id { get; private set; } public string Name { get; private set; } = string.Empty; /// Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах /// продолжаем показывать . Null/пусто — ищем по . public string? OriginalName { get; private set; } public string? Description { get; private set; } public ShowKind Kind { get; private set; } /// Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную. /// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое». public ShowAudience? Audience { get; private set; } public DateTimeOffset CreatedAt { get; private set; } // ── Метаданные (TMDb/OMDb/вручную) ── /// Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы. public string? MetadataProvider { get; private set; } /// Идентификатор шоу во внешнем источнике (для довыгрузки серий). public string? MetadataExternalId { get; private set; } public int? Year { get; private set; } /// Постер шоу — ссылка на запись общего реестра изображений (Domain/Images) или null. public Guid? PosterImageId { get; private set; } /// Серии шоу (backing-field для EF). Порядок показа — по ; /// потребители сортируют явно (см. загрузчик планировщика). public IReadOnlyList Episodes => _episodes; /// Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст. public IReadOnlyList Genres => _genres; private Show() { } public static Show Create( string name, ShowKind kind, string? description = null, string? originalName = null, ShowAudience? audience = null ) => new() { Id = Guid.NewGuid(), Name = name, OriginalName = Normalize(originalName), Kind = kind, Description = description, Audience = audience, CreatedAt = DateTimeOffset.UtcNow, }; /// Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»). public void SetAudience(ShowAudience? audience) => Audience = audience; /// Основной жанр или null, если жанры не проставлены. public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId; /// /// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным /// становится , если он попал в набор, иначе первый в списке — /// так шоу с жанрами никогда не остаётся без основного. /// public void SetGenres(IEnumerable 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; Description = description; } /// Изменить отображаемое название (на экранах). Метаданные ищутся по . public void SetName(string name) => Name = name; /// Задать/снять оригинальное название (пустая строка трактуется как отсутствие). public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName); private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); /// Добавляет серию в конец. Для допустима ровно одна серия /// (инвариант защищён самим агрегатом; вызывающий обычно проверяет заранее /// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода). public ShowEpisode AddEpisode(Guid mediaAssetId) { if (!CanAddEpisode) throw new InvalidOperationException( "Только сериал может содержать больше одной серии." ); var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1; var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition); _episodes.Add(episode); return episode; } public bool RemoveEpisode(Guid episodeId) { var episode = _episodes.FirstOrDefault(e => e.Id == episodeId); if (episode is null) return false; _episodes.Remove(episode); return true; } /// Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно /// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица. public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0; /// Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null. public void ApplyMetadata( string provider, string externalId, string? description, int? year, Guid? posterImageId ) { MetadataProvider = provider; MetadataExternalId = externalId; if (!string.IsNullOrWhiteSpace(description)) Description = description; Year = year; if (posterImageId is not null) PosterImageId = posterImageId; } /// Ручная правка метаданных (без внешнего источника). public void UpdateMetadataManual(string? description, int? year) { MetadataProvider = "manual"; MetadataExternalId = null; Description = description; Year = year; } /// Привязать/снять постер шоу (ссылка на запись реестра изображений). public void SetPosterImage(Guid? imageId) => PosterImageId = imageId; /// Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее). public void ClearMetadata() { MetadataProvider = null; MetadataExternalId = null; Year = null; PosterImageId = null; Description = null; } }