177 lines
8.9 KiB
C#
177 lines
8.9 KiB
C#
namespace TeleWave.Domain.Library;
|
||
|
||
/// <summary>
|
||
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
|
||
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
|
||
/// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
|
||
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
|
||
/// </summary>
|
||
public class Show
|
||
{
|
||
private readonly List<ShowEpisode> _episodes = [];
|
||
private readonly List<ShowGenre> _genres = [];
|
||
|
||
public Guid Id { get; private set; }
|
||
public string Name { get; private set; } = string.Empty;
|
||
|
||
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
|
||
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
|
||
public string? OriginalName { get; private set; }
|
||
|
||
public string? Description { get; private set; }
|
||
public ShowKind Kind { get; private set; }
|
||
|
||
/// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
|
||
/// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary>
|
||
public ShowAudience? Audience { get; private set; }
|
||
|
||
public DateTimeOffset CreatedAt { get; private set; }
|
||
|
||
// ── Метаданные (TMDb/OMDb/вручную) ──
|
||
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
|
||
public string? MetadataProvider { get; private set; }
|
||
|
||
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
|
||
public string? MetadataExternalId { get; private set; }
|
||
|
||
public int? Year { get; private set; }
|
||
|
||
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
|
||
public Guid? PosterImageId { get; private set; }
|
||
|
||
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
|
||
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
|
||
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
|
||
|
||
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
|
||
public IReadOnlyList<ShowGenre> 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,
|
||
};
|
||
|
||
/// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</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;
|
||
Description = description;
|
||
}
|
||
|
||
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
|
||
public void SetName(string name) => Name = name;
|
||
|
||
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
|
||
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
|
||
|
||
private static string? Normalize(string? value) =>
|
||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||
|
||
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
|
||
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
|
||
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
|
||
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
|
||
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
|
||
|
||
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
|
||
public void UpdateMetadataManual(string? description, int? year)
|
||
{
|
||
MetadataProvider = "manual";
|
||
MetadataExternalId = null;
|
||
Description = description;
|
||
Year = year;
|
||
}
|
||
|
||
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
|
||
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
|
||
|
||
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
|
||
public void ClearMetadata()
|
||
{
|
||
MetadataProvider = null;
|
||
MetadataExternalId = null;
|
||
Year = null;
|
||
PosterImageId = null;
|
||
Description = null;
|
||
}
|
||
}
|