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,71 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Broadcast;
/// <summary>
/// Обслуживание, которое раньше не делалось вовсе: чистка отрендеренных заставок, на которые больше
/// никто не ссылается. Они копятся при каждой смене пары шоу и при каждой правке блока заставки,
/// а после перехода на длинное хранение расписания их станет заметно больше.
///
/// Идёт по расписанию отдельно от генерации: та держит advisory-лок канала, и подмешивать в неё
/// удаление по всей таблице не нужно.
/// </summary>
public sealed class MaintenanceBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<MaintenanceBackgroundService> logger
) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromHours(6);
private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(2);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(StartupDelay, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
using var timer = new PeriodicTimer(Interval);
do
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка обслуживания");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
// Осиротевшей считается заставка, чей ассет не встречается ни в одной записи расписания.
// Границу по времени не ставим: окно хранения расписания уже определяет, что живо.
var removed = await db
.BumperAssets.Where(b =>
!db.ScheduleEntries.Any(e => e.MediaAssetId == b.MediaAssetId)
)
.ExecuteDeleteAsync(cancellationToken);
if (removed > 0)
logger.LogInformation("Обслуживание: удалено осиротевших заставок — {Count}", removed);
}
}
@@ -1,84 +1,96 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Broadcast;
/// <summary>
/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста —
/// без удаления существующих записей, поэтому эфир не «дёргается».
/// </summary>
public sealed class SchedulingBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<SchedulerOptions> options,
ILogger<SchedulingBackgroundService> logger
) : BackgroundService
{
private readonly SchedulerOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Небольшая задержка на старте — дать примениться миграциям/сидингу.
try
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes))
);
do
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка тика планировщика");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var generator = scope.ServiceProvider.GetRequiredService<ScheduleGenerator>();
var channelIds = await db
.Channels.Where(c => c.IsEnabled)
.Select(c => c.Id)
.ToListAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
foreach (var channelId in channelIds)
{
var added = await generator.GenerateAsync(
channelId,
now,
regenerate: false,
cancellationToken
);
if (added > 0)
logger.LogInformation(
"Канал {ChannelId}: добавлено {Count} записей расписания",
channelId,
added
);
}
}
}
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning;
namespace TeleWave.Infrastructure.Broadcast;
/// <summary>
/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста —
/// без удаления существующих записей, поэтому эфир не «дёргается».
/// </summary>
public sealed class SchedulingBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<SchedulerOptions> options,
ILogger<SchedulingBackgroundService> logger
) : BackgroundService
{
private readonly SchedulerOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Небольшая задержка на старте — дать примениться миграциям/сидингу.
try
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes))
);
do
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка тика планировщика");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var generator = scope.ServiceProvider.GetRequiredService<GridScheduleGenerator>();
// Эфир строит только шаблон сетки: канал без шаблона вещать не может и молча пропускается.
var channelIds = await db
.Channels.Where(c => c.IsEnabled && c.TemplateId != null)
.Select(c => c.Id)
.ToListAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
foreach (var channelId in channelIds)
{
var report = await generator.GenerateAsync(
channelId,
now,
rebuildFuture: false,
cancellationToken
);
if (report.Added > 0)
logger.LogInformation(
"Канал {ChannelId}: добавлено {Count} записей расписания",
channelId,
report.Added
);
foreach (var warning in report.Warnings)
logger.LogWarning(
"Канал {ChannelId}, слот {SlotId}: {Warning} — {Details}",
channelId,
warning.SlotId,
warning.Kind,
warning.Details
);
}
}
}
@@ -1,167 +1,180 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Infrastructure.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Metadata;
using TeleWave.Infrastructure.Persistence;
using TeleWave.Infrastructure.Settings;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Infrastructure;
/// <summary>
/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация.
/// </summary>
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration
)
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(
configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException(
"Строка подключения 'ConnectionStrings:Default' не сконфигурирована."
)
)
);
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services
.AddIdentityCore<AppUser>(options =>
{
options.User.RequireUniqueEmail = false;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.AllowedForNewUsers = true;
})
.AddRoles<AppRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<AdminSeedOptions>(
configuration.GetSection(AdminSeedOptions.SectionName)
);
var jwtOptions =
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана.");
// Fail-fast на подписывающем ключе: этот же ключ подписывает и JWT, и stream-токены
// (StreamTokenService), поэтому placeholder/короткий ключ из appsettings.json = полный обход
// авторизации (можно сфорджить admin-JWT). Лучше не стартовать вовсе, чем стартовать уязвимым.
// HMAC-SHA256 требует ключ не короче размера хеша (32 байта), иначе он ослаблен нулевым паддингом.
if (Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32)
throw new InvalidOperationException(
"Jwt:SigningKey должен быть не короче 32 байт. Задайте криптостойкий секрет через конфигурацию/переменную окружения Jwt__SigningKey."
);
if (jwtOptions.SigningKey.Contains("change-me", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
"Jwt:SigningKey использует значение-заглушку из appsettings.json. Переопределите его криптостойким секретом (Jwt__SigningKey)."
);
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.SigningKey)
),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
});
services.AddAuthorization();
services.AddScoped<IIdentityService, IdentityService>();
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
services.AddScoped<IRoleService, RoleService>();
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<ISiteSettings, SiteSettings>();
services.AddScoped<DbInitializer>();
AddMedia(services, configuration);
AddBroadcast(services, configuration);
AddMetadata(services, configuration);
return services;
}
/// <summary>Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок.</summary>
private static void AddMetadata(IServiceCollection services, IConfiguration configuration)
{
services.Configure<MetadataOptions>(configuration.GetSection(MetadataOptions.SectionName));
services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15));
services.AddSingleton<IMetadataProvider, TmdbMetadataProvider>();
services.AddSingleton<IMetadataProvider, OmdbMetadataProvider>();
services.AddSingleton<IMetadataProviderResolver, MetadataProviderResolver>();
}
/// <summary>Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта.</summary>
private static void AddBroadcast(IServiceCollection services, IConfiguration configuration)
{
services.Configure<SchedulerOptions>(
configuration.GetSection(SchedulerOptions.SectionName)
);
services.Configure<StreamingOptions>(
configuration.GetSection(StreamingOptions.SectionName)
);
services.Configure<BumperOptions>(configuration.GetSection(BumperOptions.SectionName));
services.AddSingleton<IRandomSource, SystemRandomSource>();
services.AddSingleton<StreamTokenService>();
services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>();
services.AddScoped<ScheduleBumperResolver>();
services.AddScoped<ScheduleGenerator>();
services.AddHostedService<SchedulingBackgroundService>();
}
/// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary>
private static void AddMedia(IServiceCollection services, IConfiguration configuration)
{
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
services.AddSingleton<MediaPathResolver>();
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
services.AddSingleton<IImageStore, ImageStore>();
services.AddSingleton<IImageDownloader, ImageDownloader>();
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
services.AddHostedService<MediaProcessingBackgroundService>();
services.AddHostedService<InboxScannerBackgroundService>();
services.AddHostedService<BumperRenderBackgroundService>();
}
}
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Infrastructure.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Library;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Metadata;
using TeleWave.Infrastructure.Persistence;
using TeleWave.Infrastructure.Settings;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Infrastructure;
/// <summary>
/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация.
/// </summary>
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration
)
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(
configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException(
"Строка подключения 'ConnectionStrings:Default' не сконфигурирована."
)
)
);
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services
.AddIdentityCore<AppUser>(options =>
{
options.User.RequireUniqueEmail = false;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.AllowedForNewUsers = true;
})
.AddRoles<AppRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<AdminSeedOptions>(
configuration.GetSection(AdminSeedOptions.SectionName)
);
var jwtOptions =
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана.");
// Fail-fast на подписывающем ключе: этот же ключ подписывает и JWT, и stream-токены
// (StreamTokenService), поэтому placeholder/короткий ключ из appsettings.json = полный обход
// авторизации (можно сфорджить admin-JWT). Лучше не стартовать вовсе, чем стартовать уязвимым.
// HMAC-SHA256 требует ключ не короче размера хеша (32 байта), иначе он ослаблен нулевым паддингом.
if (Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32)
throw new InvalidOperationException(
"Jwt:SigningKey должен быть не короче 32 байт. Задайте криптостойкий секрет через конфигурацию/переменную окружения Jwt__SigningKey."
);
if (jwtOptions.SigningKey.Contains("change-me", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
"Jwt:SigningKey использует значение-заглушку из appsettings.json. Переопределите его криптостойким секретом (Jwt__SigningKey)."
);
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.SigningKey)
),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
});
services.AddAuthorization();
services.AddScoped<IIdentityService, IdentityService>();
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
services.AddScoped<IRoleService, RoleService>();
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<ISiteSettings, SiteSettings>();
services.AddScoped<DbInitializer>();
services.AddScoped<GenreSeeder>();
services.AddScoped<GenreMatcher>();
services.AddScoped<GroupElementResolver>();
services.AddScoped<GroupStatsService>();
services.AddScoped<GroupMembershipCleaner>();
services.AddScoped<SlotWriter>();
services.AddScoped<GroupExpander>();
services.AddScoped<BumperResolver>();
services.AddScoped<GridScheduleGenerator>();
AddMedia(services, configuration);
AddBroadcast(services, configuration);
AddMetadata(services, configuration);
return services;
}
/// <summary>Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок.</summary>
private static void AddMetadata(IServiceCollection services, IConfiguration configuration)
{
services.Configure<MetadataOptions>(configuration.GetSection(MetadataOptions.SectionName));
services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15));
services.AddSingleton<IMetadataProvider, TmdbMetadataProvider>();
services.AddSingleton<IMetadataProvider, OmdbMetadataProvider>();
services.AddSingleton<IMetadataProviderResolver, MetadataProviderResolver>();
}
/// <summary>Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта.</summary>
private static void AddBroadcast(IServiceCollection services, IConfiguration configuration)
{
services.Configure<SchedulerOptions>(
configuration.GetSection(SchedulerOptions.SectionName)
);
services.Configure<StreamingOptions>(
configuration.GetSection(StreamingOptions.SectionName)
);
services.Configure<BumperOptions>(configuration.GetSection(BumperOptions.SectionName));
services.AddSingleton<IRandomSource, SystemRandomSource>();
services.AddSingleton<StreamTokenService>();
services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>();
services.AddHostedService<SchedulingBackgroundService>();
services.AddHostedService<MaintenanceBackgroundService>();
}
/// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary>
private static void AddMedia(IServiceCollection services, IConfiguration configuration)
{
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
services.AddSingleton<MediaPathResolver>();
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
services.AddSingleton<IImageStore, ImageStore>();
services.AddSingleton<IImageDownloader, ImageDownloader>();
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
services.AddHostedService<MediaProcessingBackgroundService>();
services.AddHostedService<InboxScannerBackgroundService>();
services.AddHostedService<BumperRenderBackgroundService>();
}
}
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using TeleWave.Infrastructure.Library;
namespace TeleWave.Infrastructure.Identity;
@@ -10,7 +11,11 @@ public static class DbInitializerExtensions
)
{
await using var scope = services.CreateAsyncScope();
var initializer = scope.ServiceProvider.GetRequiredService<DbInitializer>();
await initializer.SeedAsync(cancellationToken);
var genreSeeder = scope.ServiceProvider.GetRequiredService<GenreSeeder>();
await genreSeeder.SeedAsync(cancellationToken);
}
}
@@ -0,0 +1,111 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Persistence;
namespace TeleWave.Infrastructure.Library;
/// <summary>
/// Идемпотентный сидинг справочника жанров. Жанр опознаётся по <see cref="Genre.Slug"/>, поэтому
/// переименование администратором сид переживает; недостающие псевдонимы дописываются и к уже
/// существующим жанрам — так новые варианты написания приезжают с обновлением приложения.
///
/// Псевдоним, уже занятый другим жанром, пропускается: уникальность псевдонима — условие
/// однозначного сопоставления метаданных, и ручная правка администратора имеет приоритет над сидом.
/// </summary>
public sealed class GenreSeeder(AppDbContext dbContext, ILogger<GenreSeeder> logger)
{
private sealed record GenreSeed(string Slug, string Name, string[] Aliases);
// Порядок массива задаёт SortOrder — так список в UI выглядит осмысленно, а не по алфавиту.
// В псевдонимах: идентификаторы TMDb (точное сопоставление) и имена от TMDb/OMDb (запасное).
private static readonly GenreSeed[] Seeds =
[
new("action", "Боевик", ["tmdb:28", "tmdb:10759", "action", "action & adventure"]),
new("adventure", "Приключения", ["tmdb:12", "adventure"]),
new("animation", "Мультфильм", ["tmdb:16", "animation", "анимация"]),
new("comedy", "Комедия", ["tmdb:35", "comedy"]),
new("crime", "Криминал", ["tmdb:80", "crime"]),
new("drama", "Драма", ["tmdb:18", "drama"]),
new("thriller", "Триллер", ["tmdb:53", "thriller"]),
new("horror", "Ужасы", ["tmdb:27", "horror"]),
new("sci-fi", "Фантастика", ["tmdb:878", "tmdb:10765", "science fiction", "sci-fi"]),
new("fantasy", "Фэнтези", ["tmdb:14", "fantasy"]),
new("mystery", "Детектив", ["tmdb:9648", "mystery"]),
new("romance", "Мелодрама", ["tmdb:10749", "romance", "романтика"]),
new("family", "Семейный", ["tmdb:10751", "family"]),
new("kids", "Детский", ["tmdb:10762", "kids"]),
new("music", "Музыкальный", ["tmdb:10402", "music", "musical"]),
new("history", "Исторический", ["tmdb:36", "history"]),
new("war", "Военный", ["tmdb:10752", "tmdb:10768", "war", "war & politics"]),
new("western", "Вестерн", ["tmdb:37", "western"]),
new("documentary", "Документальный", ["tmdb:99", "documentary"]),
new("biography", "Биография", ["biography"]),
new("sport", "Спорт", ["sport"]),
new("news", "Новости", ["tmdb:10763", "news"]),
new("reality", "Реалити", ["tmdb:10764", "reality"]),
new("talk-show", "Ток-шоу", ["tmdb:10767", "talk", "talk-show"]),
new("soap", "Мыльная опера", ["tmdb:10766", "soap"]),
new("tv-movie", "Телефильм", ["tmdb:10770", "tv movie"]),
new("short", "Короткометражка", ["short"]),
];
public async Task SeedAsync(CancellationToken cancellationToken = default)
{
var existing = await dbContext
.Genres.Include(g => g.Aliases)
.ToDictionaryAsync(g => g.Slug, cancellationToken);
var takenAliases = existing
.Values.SelectMany(g => g.Aliases.Select(a => new { a.Value, g.Slug }))
.ToDictionary(x => x.Value, x => x.Slug);
var createdGenres = 0;
var createdAliases = 0;
for (var i = 0; i < Seeds.Length; i++)
{
var seed = Seeds[i];
if (!existing.TryGetValue(seed.Slug, out var genre))
{
genre = Genre.Create(seed.Name, seed.Slug, i, isSystem: true);
dbContext.Genres.Add(genre);
existing[seed.Slug] = genre;
createdGenres++;
}
// Само название и slug тоже годятся как варианты написания — провайдеры отдают то одно, то другое.
foreach (var alias in seed.Aliases.Append(seed.Slug).Append(seed.Name))
{
var normalized = GenreAlias.Normalize(alias);
if (normalized.Length == 0)
continue;
if (takenAliases.TryGetValue(normalized, out var owner))
{
if (owner != seed.Slug)
logger.LogDebug(
"Genre alias '{Alias}' already belongs to '{Owner}' — skipped",
normalized,
owner
);
continue;
}
genre.AddAlias(normalized);
takenAliases[normalized] = seed.Slug;
createdAliases++;
}
}
if (createdGenres == 0 && createdAliases == 0)
return;
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Seeded genres: {Genres} new, {Aliases} new aliases",
createdGenres,
createdAliases
);
}
}
@@ -62,10 +62,19 @@ public sealed class OmdbMetadataProvider(
Clean(GetString(root, "Title")) ?? "—",
YearFrom(GetString(root, "Year")),
Clean(GetString(root, "Plot")),
Clean(GetString(root, "Poster"))
Clean(GetString(root, "Poster")),
GenresFrom(Clean(GetString(root, "Genre")))
);
}
/// <summary>OMDb отдаёт жанры одной строкой через запятую: «Action, Sci-Fi».</summary>
private static List<string> GenresFrom(string? value) =>
string.IsNullOrWhiteSpace(value)
? []
: value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
public async Task<EpisodeMetadata?> GetEpisodeAsync(
string externalId,
int season,
@@ -66,10 +66,32 @@ public sealed class TmdbMetadataProvider(
GetString(root, "name") ?? "—",
YearFrom(GetString(root, "first_air_date")),
GetString(root, "overview"),
PosterUrl(GetString(root, "poster_path"))
PosterUrl(GetString(root, "poster_path")),
GenresFrom(root)
);
}
/// <summary>
/// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор
/// (<c>tmdb:28</c>), затем локализованное название. Идентификатор точнее, название — запасной
/// вариант, если справочник его ещё не знает.
/// </summary>
private static List<string> GenresFrom(JsonElement root)
{
var genres = new List<string>();
if (!root.TryGetProperty("genres", out var array) || array.ValueKind != JsonValueKind.Array)
return genres;
foreach (var item in array.EnumerateArray())
{
if (GetInt(item, "id") is { } id)
genres.Add($"tmdb:{id.ToString(CultureInfo.InvariantCulture)}");
if (GetString(item, "name") is { Length: > 0 } name)
genres.Add(name);
}
return genres;
}
public async Task<EpisodeMetadata?> GetEpisodeAsync(
string externalId,
int season,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGenres : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Genres",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Slug = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Genres", x => x.Id);
});
migrationBuilder.CreateTable(
name: "GenreAliases",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GenreId = table.Column<Guid>(type: "uuid", nullable: false),
Value = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GenreAliases", x => x.Id);
table.ForeignKey(
name: "FK_GenreAliases_Genres_GenreId",
column: x => x.GenreId,
principalTable: "Genres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ShowGenres",
columns: table => new
{
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
GenreId = table.Column<Guid>(type: "uuid", nullable: false),
IsPrimary = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ShowGenres", x => new { x.ShowId, x.GenreId });
table.ForeignKey(
name: "FK_ShowGenres_Genres_GenreId",
column: x => x.GenreId,
principalTable: "Genres",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ShowGenres_Shows_ShowId",
column: x => x.ShowId,
principalTable: "Shows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_GenreAliases_GenreId",
table: "GenreAliases",
column: "GenreId");
migrationBuilder.CreateIndex(
name: "IX_GenreAliases_Value",
table: "GenreAliases",
column: "Value",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Genres_Slug",
table: "Genres",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ShowGenres_GenreId",
table: "ShowGenres",
column: "GenreId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GenreAliases");
migrationBuilder.DropTable(
name: "ShowGenres");
migrationBuilder.DropTable(
name: "Genres");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <summary>
/// Переход возрастной категории на шкалу, упорядоченную по возрастанию строгости:
/// Kids(0) → Family(1) → Teen(2) → General(3) → Adult(4). Прежние значения (General=0, Kids=1,
/// Adult=2) отсортированы не были, а правила планировщика формулируются как «не строже X»,
/// поэтому порядок стал значимым.
/// </summary>
public partial class ShowAudienceScale : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Одним CASE, а не серией UPDATE: последовательные обновления перепутали бы категории
// между собой (сначала General 0 → 3, а следом это же значение попало бы под правило
// для старого Adult).
migrationBuilder.Sql(
"""
UPDATE "Shows" SET "Audience" = CASE "Audience"
WHEN 0 THEN 3
WHEN 1 THEN 0
WHEN 2 THEN 4
ELSE "Audience"
END;
"""
);
// Дефолт остался от первого добавления столбца и теперь означал бы «детское».
// В модели значения по умолчанию нет — снимаем и в схеме.
migrationBuilder.Sql("""ALTER TABLE "Shows" ALTER COLUMN "Audience" DROP DEFAULT;""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Family и Teen в старой шкале не существуют — схлопываются в General.
migrationBuilder.Sql(
"""
UPDATE "Shows" SET "Audience" = CASE "Audience"
WHEN 0 THEN 1
WHEN 4 THEN 2
ELSE 0
END;
"""
);
migrationBuilder.Sql("""ALTER TABLE "Shows" ALTER COLUMN "Audience" SET DEFAULT 0;""");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCollections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Collections",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
PosterImageId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Collections", x => x.Id);
});
migrationBuilder.CreateTable(
name: "CollectionItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CollectionId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CollectionItems", x => x.Id);
table.ForeignKey(
name: "FK_CollectionItems_Collections_CollectionId",
column: x => x.CollectionId,
principalTable: "Collections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CollectionItems_Shows_ShowId",
column: x => x.ShowId,
principalTable: "Shows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CollectionItems_CollectionId_Position",
table: "CollectionItems",
columns: new[] { "CollectionId", "Position" });
migrationBuilder.CreateIndex(
name: "IX_CollectionItems_CollectionId_ShowId",
table: "CollectionItems",
columns: new[] { "CollectionId", "ShowId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CollectionItems_ShowId",
table: "CollectionItems",
column: "ShowId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CollectionItems");
migrationBuilder.DropTable(
name: "Collections");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Groups",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
FilterJson = table.Column<string>(type: "jsonb", nullable: true),
ItemCount = table.Column<int>(type: "integer", nullable: false),
UnitCount = table.Column<int>(type: "integer", nullable: false),
TotalDuration = table.Column<TimeSpan>(type: "interval", nullable: false),
StatsComputedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Groups", x => x.Id);
});
migrationBuilder.CreateTable(
name: "GroupItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: false),
ElementKind = table.Column<int>(type: "integer", nullable: false),
ElementId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GroupItems", x => x.Id);
table.ForeignKey(
name: "FK_GroupItems_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_GroupItems_ElementKind_ElementId",
table: "GroupItems",
columns: new[] { "ElementKind", "ElementId" });
migrationBuilder.CreateIndex(
name: "IX_GroupItems_GroupId_ElementKind_ElementId",
table: "GroupItems",
columns: new[] { "GroupId", "ElementKind", "ElementId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GroupItems_GroupId_Position",
table: "GroupItems",
columns: new[] { "GroupId", "Position" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GroupItems");
migrationBuilder.DropTable(
name: "Groups");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddScheduleTemplate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<TimeOnly>(
name: "DayStartTime",
table: "Channels",
type: "time without time zone",
nullable: false,
defaultValue: new TimeOnly(0, 0, 0));
migrationBuilder.AddColumn<int>(
name: "Number",
table: "Channels",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "TemplateId",
table: "Channels",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "UtcOffsetMinutes",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "ScheduleTemplates",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
FallbackGroupId = table.Column<Guid>(type: "uuid", nullable: true),
Revision = table.Column<int>(type: "integer", nullable: false),
AppliedRevision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ScheduleTemplates", x => x.Id);
});
migrationBuilder.CreateTable(
name: "GridLayers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Priority = table.Column<int>(type: "integer", nullable: false),
ApplicabilityJson = table.Column<string>(type: "jsonb", nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
IsBackground = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GridLayers", x => x.Id);
table.ForeignKey(
name: "FK_GridLayers_ScheduleTemplates_TemplateId",
column: x => x.TemplateId,
principalTable: "ScheduleTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Slots",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
LayerId = table.Column<Guid>(type: "uuid", nullable: false),
Weekday = table.Column<int>(type: "integer", nullable: true),
TargetStart = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
TargetDurationMinutes = table.Column<int>(type: "integer", nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Daypart = table.Column<int>(type: "integer", nullable: false),
SlotKind = table.Column<int>(type: "integer", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
StrategyJson = table.Column<string>(type: "jsonb", nullable: true),
RepeatSourceJson = table.Column<string>(type: "jsonb", nullable: true),
BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false),
OverflowPolicy = table.Column<int>(type: "integer", nullable: false),
IsAnchor = table.Column<bool>(type: "boolean", nullable: false),
MaxDriftMinutes = table.Column<int>(type: "integer", nullable: false),
SnapToMinutes = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Slots", x => x.Id);
table.ForeignKey(
name: "FK_Slots_GridLayers_LayerId",
column: x => x.LayerId,
principalTable: "GridLayers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Slots_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "SlotStates",
columns: table => new
{
SlotId = table.Column<Guid>(type: "uuid", nullable: false),
CurrentElementKind = table.Column<int>(type: "integer", nullable: true),
CurrentElementId = table.Column<Guid>(type: "uuid", nullable: true),
NextUnitIndex = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SlotStates", x => x.SlotId);
table.ForeignKey(
name: "FK_SlotStates_Slots_SlotId",
column: x => x.SlotId,
principalTable: "Slots",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Channels_Number",
table: "Channels",
column: "Number",
unique: true,
filter: "\"Number\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_GridLayers_TemplateId_Priority",
table: "GridLayers",
columns: new[] { "TemplateId", "Priority" });
migrationBuilder.CreateIndex(
name: "IX_ScheduleTemplates_ChannelId",
table: "ScheduleTemplates",
column: "ChannelId");
migrationBuilder.CreateIndex(
name: "IX_Slots_GroupId",
table: "Slots",
column: "GroupId");
migrationBuilder.CreateIndex(
name: "IX_Slots_LayerId_TargetStart",
table: "Slots",
columns: new[] { "LayerId", "TargetStart" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SlotStates");
migrationBuilder.DropTable(
name: "Slots");
migrationBuilder.DropTable(
name: "GridLayers");
migrationBuilder.DropTable(
name: "ScheduleTemplates");
migrationBuilder.DropIndex(
name: "IX_Channels_Number",
table: "Channels");
migrationBuilder.DropColumn(
name: "DayStartTime",
table: "Channels");
migrationBuilder.DropColumn(
name: "Number",
table: "Channels");
migrationBuilder.DropColumn(
name: "TemplateId",
table: "Channels");
migrationBuilder.DropColumn(
name: "UtcOffsetMinutes",
table: "Channels");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ScheduleEntryTrace : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries");
migrationBuilder.AddColumn<Guid>(
name: "SlotId",
table: "ScheduleEntries",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TraceJson",
table: "ScheduleEntries",
type: "jsonb",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId", "StartsAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
table: "ScheduleEntries");
migrationBuilder.DropColumn(
name: "SlotId",
table: "ScheduleEntries");
migrationBuilder.DropColumn(
name: "TraceJson",
table: "ScheduleEntries");
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId" });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class DropLegacyRotation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelAd");
migrationBuilder.DropTable(
name: "ChannelShowHour");
migrationBuilder.DropTable(
name: "OverrideShow");
migrationBuilder.DropTable(
name: "ChannelShow");
migrationBuilder.DropTable(
name: "ProgrammingOverride");
migrationBuilder.DropColumn(
name: "AdInsertion",
table: "Channels");
migrationBuilder.DropColumn(
name: "AdsPerBreak",
table: "Channels");
migrationBuilder.DropColumn(
name: "NextAdIndex",
table: "Channels");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AdInsertion",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "AdsPerBreak",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "NextAdIndex",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "ChannelAd",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelAd", x => x.Id);
table.ForeignKey(
name: "FK_ChannelAd_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ChannelShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
PreferredWeightMultiplier = table.Column<int>(type: "integer", nullable: false, defaultValue: 3),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShow", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShow_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ProgrammingOverride",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
DayOfWeek = table.Column<int>(type: "integer", nullable: true),
EndMinute = table.Column<int>(type: "integer", nullable: true),
EndsAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
Mode = table.Column<int>(type: "integer", nullable: false),
Recurrence = table.Column<int>(type: "integer", nullable: false),
StartMinute = table.Column<int>(type: "integer", nullable: true),
StartsAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
table.ForeignKey(
name: "FK_ProgrammingOverride_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ChannelShowHour",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
EndHour = table.Column<int>(type: "integer", nullable: false),
StartHour = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
column: x => x.ChannelShowId,
principalTable: "ChannelShow",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OverrideShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OverrideShow", x => x.Id);
table.ForeignKey(
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
column: x => x.ProgrammingOverrideId,
principalTable: "ProgrammingOverride",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ChannelAd_ChannelId_Position",
table: "ChannelAd",
columns: new[] { "ChannelId", "Position" });
migrationBuilder.CreateIndex(
name: "IX_ChannelShow_ChannelId_ShowId",
table: "ChannelShow",
columns: new[] { "ChannelId", "ShowId" });
migrationBuilder.CreateIndex(
name: "IX_ChannelShowHour_ChannelShowId",
table: "ChannelShowHour",
column: "ChannelShowId");
migrationBuilder.CreateIndex(
name: "IX_OverrideShow_ProgrammingOverrideId",
table: "OverrideShow",
column: "ProgrammingOverrideId");
migrationBuilder.CreateIndex(
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
table: "ProgrammingOverride",
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJunctionTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "JunctionAfterId",
table: "Slots",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "JunctionBetweenId",
table: "Slots",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "DefaultJunctionId",
table: "ScheduleTemplates",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "JunctionTemplates",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_JunctionTemplates", x => x.Id);
});
migrationBuilder.CreateTable(
name: "JunctionElements",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
JunctionTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: true),
AmountMode = table.Column<int>(type: "integer", nullable: false),
AmountValue = table.Column<int>(type: "integer", nullable: false),
IsRequired = table.Column<bool>(type: "boolean", nullable: false),
ConditionsJson = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_JunctionElements", x => x.Id);
table.ForeignKey(
name: "FK_JunctionElements_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_JunctionElements_JunctionTemplates_JunctionTemplateId",
column: x => x.JunctionTemplateId,
principalTable: "JunctionTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_JunctionElements_GroupId",
table: "JunctionElements",
column: "GroupId");
migrationBuilder.CreateIndex(
name: "IX_JunctionElements_JunctionTemplateId_Position",
table: "JunctionElements",
columns: new[] { "JunctionTemplateId", "Position" });
migrationBuilder.CreateIndex(
name: "IX_JunctionTemplates_ChannelId",
table: "JunctionTemplates",
column: "ChannelId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JunctionElements");
migrationBuilder.DropTable(
name: "JunctionTemplates");
migrationBuilder.DropColumn(
name: "JunctionAfterId",
table: "Slots");
migrationBuilder.DropColumn(
name: "JunctionBetweenId",
table: "Slots");
migrationBuilder.DropColumn(
name: "DefaultJunctionId",
table: "ScheduleTemplates");
}
}
}
@@ -318,12 +318,6 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<double>("BumperEpisodeChangeChance")
.HasColumnType("double precision");
@@ -345,6 +339,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeOnly>("DayStartTime")
.HasColumnType("time without time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
@@ -359,10 +356,10 @@ namespace TeleWave.Infrastructure.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
b.Property<int>("NextBumperIndex")
.HasColumnType("integer");
b.Property<int>("NextBumperIndex")
b.Property<int?>("Number")
.HasColumnType("integer");
b.Property<string>("Slug")
@@ -370,151 +367,24 @@ namespace TeleWave.Infrastructure.Migrations
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid?>("TemplateId")
.HasColumnType("uuid");
b.Property<int>("UtcOffsetMinutes")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Number")
.IsUnique()
.HasFilter("\"Number\" IS NOT NULL");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("ChannelAd");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<int>("NextEpisodeIndex")
.HasColumnType("integer");
b.Property<int>("PreferredWeightMultiplier")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(3);
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId", "ShowId");
b.ToTable("ChannelShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelShowId")
.HasColumnType("uuid");
b.Property<int>("EndHour")
.HasColumnType("integer");
b.Property<int>("StartHour")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelShowId");
b.ToTable("ChannelShowHour");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ProgrammingOverrideId")
.HasColumnType("uuid");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProgrammingOverrideId");
b.ToTable("OverrideShow");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<int?>("DayOfWeek")
.HasColumnType("integer");
b.Property<int?>("EndMinute")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<int>("Recurrence")
.HasColumnType("integer");
b.Property<int?>("StartMinute")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc");
b.ToTable("ProgrammingOverride");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
@@ -541,17 +411,23 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<Guid?>("SlotId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("TraceJson")
.HasColumnType("jsonb");
b.HasKey("Id");
b.HasIndex("ChannelId", "EndsAtUtc");
b.HasIndex("ChannelId", "ShowId");
b.HasIndex("ChannelId", "StartsAtUtc");
b.HasIndex("ChannelId", "ShowId", "StartsAtUtc");
b.ToTable("ScheduleEntries");
});
@@ -582,6 +458,112 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("Images");
});
modelBuilder.Entity("TeleWave.Domain.Library.Collection", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("PosterImageId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("Collections");
});
modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("CollectionId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ShowId");
b.HasIndex("CollectionId", "Position");
b.HasIndex("CollectionId", "ShowId")
.IsUnique();
b.ToTable("CollectionItems");
});
modelBuilder.Entity("TeleWave.Domain.Library.Genre", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("GenreId")
.HasColumnType("uuid");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("GenreId");
b.HasIndex("Value")
.IsUnique();
b.ToTable("GenreAliases");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Property<Guid>("Id")
@@ -674,6 +656,24 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("ShowEpisode");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b =>
{
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<Guid>("GenreId")
.HasColumnType("uuid");
b.Property<bool>("IsPrimary")
.HasColumnType("boolean");
b.HasKey("ShowId", "GenreId");
b.HasIndex("GenreId");
b.ToTable("ShowGenres");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
@@ -747,6 +747,295 @@ namespace TeleWave.Infrastructure.Migrations
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("ApplicabilityJson")
.HasColumnType("jsonb");
b.Property<bool>("IsBackground")
.HasColumnType("boolean");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<Guid>("TemplateId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TemplateId", "Priority");
b.ToTable("GridLayers");
});
modelBuilder.Entity("TeleWave.Domain.Programming.Group", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("FilterJson")
.HasColumnType("jsonb");
b.Property<int>("ItemCount")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("StatsComputedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan>("TotalDuration")
.HasColumnType("interval");
b.Property<int>("UnitCount")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Groups");
});
modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ElementId")
.HasColumnType("uuid");
b.Property<int>("ElementKind")
.HasColumnType("integer");
b.Property<Guid>("GroupId")
.HasColumnType("uuid");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Weight")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ElementKind", "ElementId");
b.HasIndex("GroupId", "Position");
b.HasIndex("GroupId", "ElementKind", "ElementId")
.IsUnique();
b.ToTable("GroupItems");
});
modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AmountMode")
.HasColumnType("integer");
b.Property<int>("AmountValue")
.HasColumnType("integer");
b.Property<Guid?>("BumperTemplateId")
.HasColumnType("uuid");
b.Property<string>("ConditionsJson")
.HasColumnType("jsonb");
b.Property<Guid?>("GroupId")
.HasColumnType("uuid");
b.Property<bool>("IsRequired")
.HasColumnType("boolean");
b.Property<Guid>("JunctionTemplateId")
.HasColumnType("uuid");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<int>("Position")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("JunctionTemplateId", "Position");
b.ToTable("JunctionElements");
});
modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.ToTable("JunctionTemplates");
});
modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AppliedRevision")
.HasColumnType("integer");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DefaultJunctionId")
.HasColumnType("uuid");
b.Property<Guid?>("FallbackGroupId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("Revision")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.ToTable("ScheduleTemplates");
});
modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("BlockMode")
.HasColumnType("integer");
b.Property<int>("BlockValue")
.HasColumnType("integer");
b.Property<int>("Daypart")
.HasColumnType("integer");
b.Property<Guid?>("GroupId")
.HasColumnType("uuid");
b.Property<bool>("IsAnchor")
.HasColumnType("boolean");
b.Property<Guid?>("JunctionAfterId")
.HasColumnType("uuid");
b.Property<Guid?>("JunctionBetweenId")
.HasColumnType("uuid");
b.Property<Guid>("LayerId")
.HasColumnType("uuid");
b.Property<int>("MaxDriftMinutes")
.HasColumnType("integer");
b.Property<int>("OverflowPolicy")
.HasColumnType("integer");
b.Property<string>("RepeatSourceJson")
.HasColumnType("jsonb");
b.Property<int>("SlotKind")
.HasColumnType("integer");
b.Property<int?>("SnapToMinutes")
.HasColumnType("integer");
b.Property<string>("StrategyJson")
.HasColumnType("jsonb");
b.Property<int>("TargetDurationMinutes")
.HasColumnType("integer");
b.Property<TimeOnly>("TargetStart")
.HasColumnType("time without time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("Weekday")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("GroupId");
b.HasIndex("LayerId", "TargetStart");
b.ToTable("Slots");
});
modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b =>
{
b.Property<Guid>("SlotId")
.HasColumnType("uuid");
b.Property<Guid?>("CurrentElementId")
.HasColumnType("uuid");
b.Property<int?>("CurrentElementKind")
.HasColumnType("integer");
b.Property<int>("NextUnitIndex")
.HasColumnType("integer");
b.HasKey("SlotId");
b.ToTable("SlotStates");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
@@ -933,47 +1222,26 @@ namespace TeleWave.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Ads")
.HasForeignKey("ChannelId")
b.HasOne("TeleWave.Domain.Library.Collection", null)
.WithMany("Items")
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TeleWave.Domain.Library.Show", null)
.WithMany()
.HasForeignKey("ShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Shows")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null)
.WithMany("PreferredHours")
.HasForeignKey("ChannelShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null)
.WithMany("Shows")
.HasForeignKey("ProgrammingOverrideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Overrides")
.HasForeignKey("ChannelId")
b.HasOne("TeleWave.Domain.Library.Genre", null)
.WithMany("Aliases")
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
@@ -987,6 +1255,76 @@ namespace TeleWave.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b =>
{
b.HasOne("TeleWave.Domain.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("TeleWave.Domain.Library.Show", null)
.WithMany("Genres")
.HasForeignKey("ShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
{
b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null)
.WithMany("Layers")
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b =>
{
b.HasOne("TeleWave.Domain.Programming.Group", null)
.WithMany("Items")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b =>
{
b.HasOne("TeleWave.Domain.Programming.Group", null)
.WithMany()
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null)
.WithMany("Elements")
.HasForeignKey("JunctionTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b =>
{
b.HasOne("TeleWave.Domain.Programming.Group", null)
.WithMany()
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("TeleWave.Domain.Programming.GridLayer", null)
.WithMany("Slots")
.HasForeignKey("LayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b =>
{
b.HasOne("TeleWave.Domain.Programming.Slot", null)
.WithOne()
.HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.Navigation("Variants");
@@ -994,28 +1332,44 @@ namespace TeleWave.Infrastructure.Migrations
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Navigation("Ads");
b.Navigation("BumperTemplates");
b.Navigation("Overrides");
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
modelBuilder.Entity("TeleWave.Domain.Library.Collection", b =>
{
b.Navigation("PreferredHours");
b.Navigation("Items");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
modelBuilder.Entity("TeleWave.Domain.Library.Genre", b =>
{
b.Navigation("Shows");
b.Navigation("Aliases");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Navigation("Episodes");
b.Navigation("Genres");
});
modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
{
b.Navigation("Slots");
});
modelBuilder.Entity("TeleWave.Domain.Programming.Group", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b =>
{
b.Navigation("Elements");
});
modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b =>
{
b.Navigation("Layers");
});
#pragma warning restore 612, 618
}
@@ -1,69 +1,83 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Settings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Infrastructure.Persistence;
/// <summary>
/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена
/// (добавляются по мере реализации фич).
/// </summary>
public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<AppUser, AppRole, Guid>(options),
IAppDbContext
{
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
public DbSet<Show> Shows => Set<Show>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<Image> Images => Set<Image>();
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
Database.BeginTransactionAsync(cancellationToken);
public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken)
{
// pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции.
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
// корректности не нарушают.
var key = BitConverter.ToInt64(channelId.ToByteArray());
return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
// Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный
// ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию
// отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0».
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (
entityType.ClrType.Namespace?.StartsWith(
"TeleWave.Domain",
StringComparison.Ordinal
) != true
)
continue;
var idProperty = entityType.FindProperty("Id");
if (idProperty is not null && idProperty.ClrType == typeof(Guid))
idProperty.ValueGenerated = ValueGenerated.Never;
}
}
}
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Infrastructure.Persistence;
/// <summary>
/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена
/// (добавляются по мере реализации фич).
/// </summary>
public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<AppUser, AppRole, Guid>(options),
IAppDbContext
{
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
public DbSet<Show> Shows => Set<Show>();
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreAlias> GenreAliases => Set<GenreAlias>();
public DbSet<ShowGenre> ShowGenres => Set<ShowGenre>();
public DbSet<Collection> Collections => Set<Collection>();
public DbSet<CollectionItem> CollectionItems => Set<CollectionItem>();
public DbSet<Group> Groups => Set<Group>();
public DbSet<GroupItem> GroupItems => Set<GroupItem>();
public DbSet<ScheduleTemplate> ScheduleTemplates => Set<ScheduleTemplate>();
public DbSet<GridLayer> GridLayers => Set<GridLayer>();
public DbSet<Slot> Slots => Set<Slot>();
public DbSet<SlotState> SlotStates => Set<SlotState>();
public DbSet<JunctionTemplate> JunctionTemplates => Set<JunctionTemplate>();
public DbSet<JunctionElement> JunctionElements => Set<JunctionElement>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<Image> Images => Set<Image>();
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
Database.BeginTransactionAsync(cancellationToken);
public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken)
{
// pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции.
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
// корректности не нарушают.
var key = BitConverter.ToInt64(channelId.ToByteArray());
return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
// Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный
// ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию
// отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0».
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (
entityType.ClrType.Namespace?.StartsWith(
"TeleWave.Domain",
StringComparison.Ordinal
) != true
)
continue;
var idProperty = entityType.FindProperty("Id");
if (idProperty is not null && idProperty.ClrType == typeof(Guid))
idProperty.ValueGenerated = ValueGenerated.Never;
}
}
}
@@ -1,133 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
{
public void Configure(EntityTypeBuilder<Channel> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.Slug).IsRequired().HasMaxLength(128);
builder.HasIndex(x => x.Slug).IsUnique();
builder
.HasMany(x => x.Shows)
.WithOne()
.HasForeignKey(s => s.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Shows).UsePropertyAccessMode(PropertyAccessMode.Field);
builder
.HasMany(x => x.Ads)
.WithOne()
.HasForeignKey(a => a.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Ads).UsePropertyAccessMode(PropertyAccessMode.Field);
builder
.HasMany(x => x.BumperTemplates)
.WithOne()
.HasForeignKey(t => t.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field);
builder
.HasMany(x => x.Overrides)
.WithOne()
.HasForeignKey(o => o.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Overrides).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class ChannelShowConfiguration : IEntityTypeConfiguration<ChannelShow>
{
public void Configure(EntityTypeBuilder<ChannelShow> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.ShowId });
builder
.Property(x => x.PreferredWeightMultiplier)
.HasDefaultValue(ChannelShow.DefaultPreferredWeightMultiplier);
builder
.HasMany(x => x.PreferredHours)
.WithOne()
.HasForeignKey(h => h.ChannelShowId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.PreferredHours).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class ChannelShowHourConfiguration : IEntityTypeConfiguration<ChannelShowHour>
{
public void Configure(EntityTypeBuilder<ChannelShowHour> builder)
{
builder.HasIndex(x => x.ChannelShowId);
}
}
public class ChannelAdConfiguration : IEntityTypeConfiguration<ChannelAd>
{
public void Configure(EntityTypeBuilder<ChannelAd> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.Position });
}
}
public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTemplate>
{
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.Position });
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.AudioExtension).HasMaxLength(16);
builder
.HasMany(x => x.Variants)
.WithOne()
.HasForeignKey(v => v.BumperTemplateId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTextVariant>
{
public void Configure(EntityTypeBuilder<BumperTextVariant> builder)
{
builder.HasIndex(x => new { x.BumperTemplateId, x.Position });
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64);
builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64);
builder.Property(x => x.Line1).IsRequired().HasMaxLength(120);
builder.Property(x => x.Line2).IsRequired().HasMaxLength(120);
builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight);
}
}
public class ProgrammingOverrideConfiguration : IEntityTypeConfiguration<ProgrammingOverride>
{
public void Configure(EntityTypeBuilder<ProgrammingOverride> builder)
{
builder.HasIndex(x => new
{
x.ChannelId,
x.StartsAtUtc,
x.EndsAtUtc,
});
builder
.HasMany(x => x.Shows)
.WithOne()
.HasForeignKey(s => s.ProgrammingOverrideId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Shows).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
{
public void Configure(EntityTypeBuilder<Channel> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.Slug).IsRequired().HasMaxLength(128);
builder.HasIndex(x => x.Slug).IsUnique();
// Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно.
builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"Number\" IS NOT NULL");
builder
.HasMany(x => x.BumperTemplates)
.WithOne()
.HasForeignKey(t => t.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTemplate>
{
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.Position });
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.AudioExtension).HasMaxLength(16);
builder
.HasMany(x => x.Variants)
.WithOne()
.HasForeignKey(v => v.BumperTemplateId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTextVariant>
{
public void Configure(EntityTypeBuilder<BumperTextVariant> builder)
{
builder.HasIndex(x => new { x.BumperTemplateId, x.Position });
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64);
builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64);
builder.Property(x => x.Line1).IsRequired().HasMaxLength(120);
builder.Property(x => x.Line2).IsRequired().HasMaxLength(120);
builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight);
}
}
@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Library;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class CollectionConfiguration : IEntityTypeConfiguration<Collection>
{
public void Configure(EntityTypeBuilder<Collection> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.Description).HasMaxLength(2048);
builder
.HasMany(x => x.Items)
.WithOne()
.HasForeignKey(i => i.CollectionId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Items).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class CollectionItemConfiguration : IEntityTypeConfiguration<CollectionItem>
{
public void Configure(EntityTypeBuilder<CollectionItem> builder)
{
builder.HasIndex(x => new { x.CollectionId, x.Position });
// Шоу входит в коллекцию не более одного раза — иначе порядок воспроизведения неоднозначен.
builder.HasIndex(x => new { x.CollectionId, x.ShowId }).IsUnique();
builder.HasIndex(x => x.ShowId);
// Удаление шоу из библиотеки убирает его и из коллекций: висячая позиция сломала бы
// разворачивание коллекции в последовательность серий при генерации.
builder
.HasOne<Show>()
.WithMany()
.HasForeignKey(x => x.ShowId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Library;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class GenreConfiguration : IEntityTypeConfiguration<Genre>
{
public void Configure(EntityTypeBuilder<Genre> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(128);
builder.Property(x => x.Slug).IsRequired().HasMaxLength(64);
builder.HasIndex(x => x.Slug).IsUnique();
builder
.HasMany(x => x.Aliases)
.WithOne()
.HasForeignKey(a => a.GenreId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Aliases).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class GenreAliasConfiguration : IEntityTypeConfiguration<GenreAlias>
{
public void Configure(EntityTypeBuilder<GenreAlias> builder)
{
builder.Property(x => x.Value).IsRequired().HasMaxLength(128);
// Псевдоним однозначно указывает на жанр: иначе сопоставление метаданных стало бы неопределённым.
builder.HasIndex(x => x.Value).IsUnique();
}
}
public class ShowGenreConfiguration : IEntityTypeConfiguration<ShowGenre>
{
public void Configure(EntityTypeBuilder<ShowGenre> builder)
{
builder.HasKey(x => new { x.ShowId, x.GenreId });
builder.HasIndex(x => x.GenreId);
// Restrict, а не Cascade: удаление жанра из справочника не должно молча снимать его со всех шоу.
// Команда удаления проверяет использование заранее и возвращает управляемый конфликт.
builder
.HasOne<Genre>()
.WithMany()
.HasForeignKey(x => x.GenreId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Programming;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class GroupConfiguration : IEntityTypeConfiguration<Group>
{
public void Configure(EntityTypeBuilder<Group> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.Description).HasMaxLength(2048);
// Правило набора хранится как jsonb: схему знает Application, а домен передаёт строку как есть.
builder.Property(x => x.FilterJson).HasColumnType("jsonb");
builder
.HasMany(x => x.Items)
.WithOne()
.HasForeignKey(i => i.GroupId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Items).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class GroupItemConfiguration : IEntityTypeConfiguration<GroupItem>
{
public void Configure(EntityTypeBuilder<GroupItem> builder)
{
builder.HasIndex(x => new { x.GroupId, x.Position });
// Элемент входит в группу не более одного раза — иначе вес и порядок становятся неоднозначны.
builder.HasIndex(x => new
{
x.GroupId,
x.ElementKind,
x.ElementId,
}).IsUnique();
// По этому индексу чистятся позиции при удалении шоу/коллекции: внешнего ключа на
// полиморфную ссылку нет, удаление идёт командой.
builder.HasIndex(x => new { x.ElementKind, x.ElementId });
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Programming;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class JunctionTemplateConfiguration : IEntityTypeConfiguration<JunctionTemplate>
{
public void Configure(EntityTypeBuilder<JunctionTemplate> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(128);
builder.HasIndex(x => x.ChannelId);
builder
.HasMany(x => x.Elements)
.WithOne()
.HasForeignKey(e => e.JunctionTemplateId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Elements).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class JunctionElementConfiguration : IEntityTypeConfiguration<JunctionElement>
{
public void Configure(EntityTypeBuilder<JunctionElement> builder)
{
builder.HasIndex(x => new { x.JunctionTemplateId, x.Position });
builder.Property(x => x.ConditionsJson).HasColumnType("jsonb");
// Группа не удаляется, пока на неё ссылается врезка: иначе стык молча перестал бы работать.
builder
.HasOne<Group>()
.WithMany()
.HasForeignKey(x => x.GroupId)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -11,6 +11,14 @@ public class ScheduleEntryConfiguration : IEntityTypeConfiguration<ScheduleEntry
// Основные запросы эфира/EPG — по каналу и времени.
builder.HasIndex(x => new { x.ChannelId, x.StartsAtUtc });
builder.HasIndex(x => new { x.ChannelId, x.EndsAtUtc });
builder.HasIndex(x => new { x.ChannelId, x.ShowId });
// Остывание спрашивает «когда этот элемент играл в последний раз» — по этому индексу.
builder.HasIndex(x => new
{
x.ChannelId,
x.ShowId,
x.StartsAtUtc,
});
builder.Property(x => x.TraceJson).HasColumnType("jsonb");
}
}
@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Programming;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class ScheduleTemplateConfiguration : IEntityTypeConfiguration<ScheduleTemplate>
{
public void Configure(EntityTypeBuilder<ScheduleTemplate> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.HasIndex(x => x.ChannelId);
builder
.HasMany(x => x.Layers)
.WithOne()
.HasForeignKey(l => l.TemplateId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Layers).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class GridLayerConfiguration : IEntityTypeConfiguration<GridLayer>
{
public void Configure(EntityTypeBuilder<GridLayer> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(128);
builder.Property(x => x.ApplicabilityJson).HasColumnType("jsonb");
builder.HasIndex(x => new { x.TemplateId, x.Priority });
builder
.HasMany(x => x.Slots)
.WithOne()
.HasForeignKey(s => s.LayerId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Slots).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class SlotConfiguration : IEntityTypeConfiguration<Slot>
{
public void Configure(EntityTypeBuilder<Slot> builder)
{
builder.Property(x => x.Title).IsRequired().HasMaxLength(256);
builder.Property(x => x.StrategyJson).HasColumnType("jsonb");
builder.Property(x => x.RepeatSourceJson).HasColumnType("jsonb");
builder.HasIndex(x => new { x.LayerId, x.TargetStart });
builder.HasIndex(x => x.GroupId);
// Группа не удаляется, пока на неё ссылается слот: иначе слот молча превратился бы в дыру,
// и админ узнал бы об этом только из эфира.
builder
.HasOne<Group>()
.WithMany()
.HasForeignKey(x => x.GroupId)
.OnDelete(DeleteBehavior.Restrict);
}
}
public class SlotStateConfiguration : IEntityTypeConfiguration<SlotState>
{
public void Configure(EntityTypeBuilder<SlotState> builder)
{
builder.HasKey(x => x.SlotId);
// Состояние живёт ровно столько же, сколько слот.
builder
.HasOne<Slot>()
.WithOne()
.HasForeignKey<SlotState>(x => x.SlotId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -21,6 +21,13 @@ public class ShowConfiguration : IEntityTypeConfiguration<Show>
.HasForeignKey(e => e.ShowId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Episodes).UsePropertyAccessMode(PropertyAccessMode.Field);
builder
.HasMany(x => x.Genres)
.WithOne()
.HasForeignKey(g => g.ShowId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Genres).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}