Implement episode metadata management: add functionality to refresh episode metadata from external sources, including new API endpoints and UI integration. Enhance Show and Episode models to support additional metadata fields, and update database schema accordingly. Update ShowDetail and ShowMetadataCard components to display refreshed episode information and provide user feedback on metadata updates.

This commit is contained in:
Leonid Pershin
2026-07-25 09:46:19 +03:00
parent 0d2dee815e
commit 7fb46b5e0d
30 changed files with 1563 additions and 81 deletions
@@ -6,6 +6,7 @@ using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.ApplyShowMetadata;
using TeleWave.Application.Metadata.ClearShowMetadata;
using TeleWave.Application.Metadata.GetProviders;
using TeleWave.Application.Metadata.RefreshEpisodes;
using TeleWave.Application.Metadata.SearchShows;
using TeleWave.Application.Metadata.SetShowPoster;
using TeleWave.Application.Metadata.UpdateShowMetadata;
@@ -39,11 +40,15 @@ public static class MetadataEndpoints
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/shows/{showId:guid}/poster", UploadPoster).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
// Постеры отдаём публично (просто картинки, id не угадать) — чтобы работал <img src>.
// Постеры/кадры отдаём публично (просто картинки, id не угадать) — чтобы работал <img src>.
app.MapGet("/api/metadata/shows/{showId:guid}/poster", ServePoster)
.WithTags("Metadata")
.Produces(StatusCodes.Status200OK);
app.MapGet("/api/metadata/episodes/{episodeId:guid}/still", ServeStill)
.WithTags("Metadata")
.Produces(StatusCodes.Status200OK);
return app;
}
@@ -126,6 +131,19 @@ public static class MetadataEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> RefreshEpisodes(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RefreshShowEpisodesMetadataCommand(showId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ServePoster(
Guid showId,
IAppDbContext dbContext,
@@ -137,14 +155,30 @@ public static class MetadataEndpoints
.Where(s => s.Id == showId)
.Select(s => s.PosterPath)
.FirstOrDefaultAsync(cancellationToken);
if (string.IsNullOrEmpty(path))
return Results.NotFound();
return ServeImage(path, imageStore);
}
var abs = imageStore.ResolveAbsolutePath(path);
if (abs is null)
return Results.NotFound();
private static async Task<IResult> ServeStill(
Guid episodeId,
IAppDbContext dbContext,
IMetadataImageStore imageStore,
CancellationToken cancellationToken
)
{
var path = await dbContext.Shows.AsNoTracking()
.SelectMany(s => s.Episodes)
.Where(e => e.Id == episodeId)
.Select(e => e.StillPath)
.FirstOrDefaultAsync(cancellationToken);
return ServeImage(path, imageStore);
}
return Results.File(abs, ContentTypeFor(Path.GetExtension(abs)));
private static IResult ServeImage(string? relativePath, IMetadataImageStore imageStore)
{
if (string.IsNullOrEmpty(relativePath))
return Results.NotFound();
var abs = imageStore.ResolveAbsolutePath(relativePath);
return abs is null ? Results.NotFound() : Results.File(abs, ContentTypeFor(Path.GetExtension(abs)));
}
private static string ContentTypeFor(string extension) =>
@@ -24,6 +24,7 @@ public sealed class ScheduleGenerator(
IRandomSource random,
IBumperRenderer bumperRenderer,
IBumperTemplateStorage bumperStorage,
IMetadataImageStore metadataImages,
IOptions<SchedulerOptions> options,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions,
@@ -192,6 +193,13 @@ public sealed class ScheduleGenerator(
var fromIds = pairs.Select(p => p.From).Distinct().ToList();
var toIds = pairs.Select(p => p.To).Distinct().ToList();
// Постеры шоу-получателей — как фон заставки (если у канала нет своего фона).
var showIds = fromIds.Concat(toIds).Distinct().ToList();
var posterByShow = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.PosterPath != null)
.Select(s => new { s.Id, s.PosterPath })
.ToDictionaryAsync(s => s.Id, s => s.PosterPath!, cancellationToken);
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
var cached = await dbContext.BumperAssets.AsNoTracking()
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
@@ -215,7 +223,8 @@ public sealed class ScheduleGenerator(
{
var fromName = showNames.GetValueOrDefault(pair.From, "…");
var toName = showNames.GetValueOrDefault(pair.To, "…");
var signature = ComputeSignature(fromName, toName, styleSignature);
var toPosterRel = posterByShow.GetValueOrDefault(pair.To);
var signature = ComputeSignature(fromName, toName, styleSignature, toPosterRel ?? "-");
var hit = cached.FirstOrDefault(c =>
c.FromShowId == pair.From
@@ -238,6 +247,7 @@ public sealed class ScheduleGenerator(
fromName,
toName,
signature,
toPosterRel,
cancellationToken
);
result[pair] = assetId;
@@ -263,13 +273,17 @@ public sealed class ScheduleGenerator(
string fromName,
string toName,
string signature,
string? toPosterRelative,
CancellationToken cancellationToken
)
{
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
var posterAbs = toPosterRelative is null
? null
: metadataImages.ResolveAbsolutePath(toPosterRelative);
var render = await bumperRenderer.RenderAsync(
asset.Id,
BuildSpec(channel, fromName, toName),
BuildSpec(channel, fromName, toName, posterAbs),
cancellationToken
);
@@ -291,7 +305,12 @@ public sealed class ScheduleGenerator(
return asset.Id;
}
private BumperRenderSpec BuildSpec(Channel channel, string fromName, string toName) =>
private BumperRenderSpec BuildSpec(
Channel channel,
string fromName,
string toName,
string? posterAbsolutePath
) =>
new(
AlignedBumperDuration(channel),
_bumper.Width,
@@ -306,7 +325,8 @@ public sealed class ScheduleGenerator(
channel.BumperNextLabel,
toName,
bumperStorage.BackgroundPath(channel.Id, channel.BumperBackgroundExtension),
bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension)
bumperStorage.MusicPath(channel.Id, channel.BumperMusicExtension),
posterAbsolutePath
);
private string FontPath(BumperFont font) =>
@@ -340,9 +360,14 @@ public sealed class ScheduleGenerator(
channel.BumperMusicExtension ?? "-"
);
private static string ComputeSignature(string fromName, string toName, string styleSignature)
private static string ComputeSignature(
string fromName,
string toName,
string styleSignature,
string poster
)
{
var raw = string.Join('', fromName, toName, styleSignature);
var raw = string.Join('', fromName, toName, styleSignature, poster);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(hash);
}
@@ -19,7 +19,9 @@ public sealed record BumperRenderSpec(
string NextLabel,
string NextTitle,
string? BackgroundFile = null,
string? MusicFile = null
string? MusicFile = null,
/// <summary>Постер шоу как фон (используется, если нет загруженного фона канала; затемняется).</summary>
string? PosterFile = null
);
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
@@ -23,6 +23,15 @@ public interface IMetadataImageStore
void DeleteShowImages(Guid showId);
/// <summary>Скачивает кадр серии по URL. Возвращает относительный путь или null при ошибке.</summary>
Task<string?> DownloadEpisodeStillAsync(
Guid episodeId,
string url,
CancellationToken cancellationToken
);
void DeleteEpisodeImages(Guid episodeId);
/// <summary>Абсолютный путь к файлу по относительному (с защитой от traversal) или null, если вне корня.</summary>
string? ResolveAbsolutePath(string relativePath);
}
@@ -22,14 +22,17 @@ public sealed class AddEpisodeCommandHandler(IAppDbContext dbContext)
if (!show.CanAddEpisode)
return Result.Failure<Guid>(ShowErrors.SingleAlreadyHasEpisode);
var assetExists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == command.MediaAssetId,
cancellationToken
);
if (!assetExists)
var fileName = await dbContext.MediaAssets
.Where(a => a.Id == command.MediaAssetId)
.Select(a => a.OriginalFileName)
.FirstOrDefaultAsync(cancellationToken);
if (fileName is null)
return Result.Failure<Guid>(ShowErrors.AssetNotFound);
var episode = show.AddEpisode(command.MediaAssetId);
if (EpisodeName.Parse(fileName) is { } parsed)
episode.SetNumbers(parsed.Season, parsed.Episode);
return Result.Success(episode.Id);
}
}
@@ -17,21 +17,35 @@ public static class EpisodeName
RegexOptions.Compiled | RegexOptions.IgnoreCase
);
// Ведущий номер серии: «01. Название», «02 - Название», «03_Название» — сезон считаем первым.
private static readonly Regex LeadingNumber = new(
@"^\s*(\d{1,3})[\s._)\]-]",
RegexOptions.Compiled
);
public static (int Season, int Episode)? Parse(string? name)
{
if (string.IsNullOrEmpty(name))
return null;
var match = SxxEyy.Match(name);
if (!match.Success)
match = NxNN.Match(name);
if (match.Success)
return (Int(match, 1), Int(match, 2));
return match.Success
? (int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture),
int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture))
: null;
match = NxNN.Match(name);
if (match.Success)
return (Int(match, 1), Int(match, 2));
match = LeadingNumber.Match(name);
if (match.Success)
return (1, Int(match, 1));
return null;
}
private static int Int(Match match, int group) =>
int.Parse(match.Groups[group].Value, CultureInfo.InvariantCulture);
public static int? ParseSeason(string? name) => Parse(name)?.Season;
/// <summary>Метка вида «S16E03», либо null если распознать не удалось.</summary>
@@ -39,7 +39,13 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
e.Position,
asset?.OriginalFileName,
asset?.Status,
asset?.Duration?.TotalSeconds
asset?.Duration?.TotalSeconds,
e.Season,
e.Episode,
e.Title,
e.Overview,
e.StillPath is not null,
e.AirDate
);
})
.ToList();
@@ -20,7 +20,13 @@ public sealed record EpisodeDto(
int Position,
string? AssetName,
MediaAssetStatus? AssetStatus,
double? DurationSeconds
double? DurationSeconds,
int? Season,
int? Episode,
string? Title,
string? Overview,
bool HasStill,
DateOnly? AirDate
);
public sealed record ShowDto(
@@ -18,4 +18,9 @@ public static class MetadataErrors
"Metadata.InvalidPoster",
"Недопустимый файл постера (формат или размер)."
);
public static readonly Error NoLinkedSource = Error.Validation(
"Metadata.NoLinkedSource",
"У шоу не привязан внешний источник — сначала найдите шоу в TMDb/OMDb."
);
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Metadata.RefreshEpisodes;
/// <summary>Довыгрузить метаданные всех серий шоу из привязанного источника. Возвращает число обновлённых.</summary>
public sealed record RefreshShowEpisodesMetadataCommand(Guid ShowId) : ICommand<Result<int>>;
@@ -0,0 +1,83 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Library;
namespace TeleWave.Application.Metadata.RefreshEpisodes;
public sealed class RefreshShowEpisodesMetadataCommandHandler(
IAppDbContext dbContext,
IMetadataProviderResolver resolver,
IMetadataImageStore imageStore
) : ICommandHandler<RefreshShowEpisodesMetadataCommand, Result<int>>
{
public async Task<Result<int>> Handle(
RefreshShowEpisodesMetadataCommand command,
CancellationToken cancellationToken
)
{
var show = await dbContext.Shows
.Include(s => s.Episodes)
.FirstOrDefaultAsync(s => s.Id == command.ShowId, cancellationToken);
if (show is null)
return Result.Failure<int>(ShowErrors.NotFound);
if (string.IsNullOrEmpty(show.MetadataExternalId))
return Result.Failure<int>(MetadataErrors.NoLinkedSource);
var provider = show.MetadataProvider is { } key ? resolver.Resolve(key) : null;
if (provider is null)
return Result.Failure<int>(MetadataErrors.ProviderNotAvailable);
// Имена файлов — чтобы распознать номера у серий, где они ещё не проставлены.
var assetIds = show.Episodes.Select(e => e.MediaAssetId).Distinct().ToList();
var names = await dbContext.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
var updated = 0;
foreach (var episode in show.Episodes)
{
var season = episode.Season;
var number = episode.Episode;
if (season is null || number is null)
{
if (
names.TryGetValue(episode.MediaAssetId, out var name)
&& EpisodeName.Parse(name) is { } parsed
)
{
season = parsed.Season;
number = parsed.Episode;
episode.SetNumbers(season, number);
}
}
if (season is null || number is null)
continue;
var meta = await provider.GetEpisodeAsync(
show.MetadataExternalId,
season.Value,
number.Value,
cancellationToken
);
if (meta is null)
continue;
string? stillPath = null;
if (!string.IsNullOrEmpty(meta.StillUrl))
stillPath = await imageStore.DownloadEpisodeStillAsync(
episode.Id,
meta.StillUrl,
cancellationToken
);
episode.ApplyMetadata(meta.Title, meta.Overview, stillPath, meta.AirDate);
updated++;
}
return Result.Success(updated);
}
}
@@ -1,8 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Streaming.GetPublicEpg;
public sealed record GetPublicEpgQuery(string Slug, DateTimeOffset FromUtc, DateTimeOffset ToUtc)
: IQuery<Result<IReadOnlyList<ScheduleEntryDto>>>;
: IQuery<Result<IReadOnlyList<PublicEpgEntryDto>>>;
@@ -7,9 +7,9 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Streaming.GetPublicEpg;
public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetPublicEpgQuery, Result<IReadOnlyList<ScheduleEntryDto>>>
: IQueryHandler<GetPublicEpgQuery, Result<IReadOnlyList<PublicEpgEntryDto>>>
{
public async Task<Result<IReadOnlyList<ScheduleEntryDto>>> Handle(
public async Task<Result<IReadOnlyList<PublicEpgEntryDto>>> Handle(
GetPublicEpgQuery query,
CancellationToken cancellationToken
)
@@ -19,7 +19,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
.Select(c => (Guid?)c.Id)
.FirstOrDefaultAsync(cancellationToken);
if (channelId is null)
return Result.Failure<IReadOnlyList<ScheduleEntryDto>>(ChannelErrors.NotFound);
return Result.Failure<IReadOnlyList<PublicEpgEntryDto>>(ChannelErrors.NotFound);
var entries = await dbContext.ScheduleEntries.AsNoTracking()
.Where(e =>
@@ -28,28 +28,65 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
&& e.EndsAtUtc > query.FromUtc
)
.OrderBy(e => e.StartsAtUtc)
.ToListAsync(cancellationToken);
var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList();
var showNames = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var dtos = entries
.Select(e => new ScheduleEntryDto(
e.Id,
.Select(e => new
{
e.Kind,
e.MediaAssetId,
e.StartsAtUtc,
e.EndsAtUtc,
e.ShowId,
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
e.EpisodeIndex,
null // сезон/серию зрителю не показываем (и не светим имена файлов)
))
e.MediaAssetId,
})
.ToListAsync(cancellationToken);
var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList();
var shows = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name, s.PosterPath })
.ToDictionaryAsync(s => s.Id, cancellationToken);
// Метаданные серий: ключ — (шоу, ассет).
var assetIds = entries.Select(e => e.MediaAssetId).Distinct().ToList();
var episodes = await dbContext.Shows.AsNoTracking()
.SelectMany(s => s.Episodes)
.Where(e => showIds.Contains(e.ShowId) && assetIds.Contains(e.MediaAssetId))
.Select(e => new
{
e.ShowId,
e.MediaAssetId,
e.Id,
e.Title,
e.Overview,
e.StillPath,
})
.ToListAsync(cancellationToken);
var episodeByKey = episodes
.GroupBy(e => (e.ShowId, e.MediaAssetId))
.ToDictionary(g => g.Key, g => g.First());
var dtos = entries
.Select(e =>
{
var show = e.ShowId is { } sid ? shows.GetValueOrDefault(sid) : null;
var episode =
e.ShowId is { } showId
&& episodeByKey.TryGetValue((showId, e.MediaAssetId), out var ep)
? ep
: null;
return new PublicEpgEntryDto(
e.Kind,
e.StartsAtUtc,
e.EndsAtUtc,
e.ShowId,
show?.Name,
show?.PosterPath is not null,
episode?.Id,
episode?.Title,
episode?.Overview,
episode?.StillPath is not null
);
})
.ToList();
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
return Result.Success<IReadOnlyList<PublicEpgEntryDto>>(dtos);
}
}
@@ -1,6 +1,7 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming.ListPublicChannels;
@@ -12,10 +13,52 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
CancellationToken cancellationToken
)
{
return await dbContext.Channels.AsNoTracking()
var channels = await dbContext.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
.OrderBy(c => c.Name)
.Select(c => new PublicChannelDto(c.Id, c.Slug, c.Name))
.Select(c => new { c.Id, c.Slug, c.Name })
.ToListAsync(cancellationToken);
if (channels.Count == 0)
return [];
var now = DateTimeOffset.UtcNow;
var channelIds = channels.Select(c => c.Id).ToList();
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
var currentByChannel = await dbContext.ScheduleEntries.AsNoTracking()
.Where(e =>
channelIds.Contains(e.ChannelId)
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc <= now
&& e.EndsAtUtc > now
&& e.ShowId != null
)
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
.ToListAsync(cancellationToken);
var currentShowByChannel = currentByChannel
.GroupBy(x => x.ChannelId)
.ToDictionary(g => g.Key, g => g.First().ShowId);
var showIds = currentShowByChannel.Values.Distinct().ToList();
var shows = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name, s.PosterPath })
.ToDictionaryAsync(s => s.Id, cancellationToken);
return channels
.Select(c =>
{
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
return new PublicChannelDto(
c.Id,
c.Slug,
c.Name,
show is null ? null : showId,
show?.Name,
show?.PosterPath is not null
);
})
.ToList();
}
}
@@ -1,6 +1,29 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto(Guid Id, string Slug, string Name);
public sealed record PublicChannelDto(
Guid Id,
string Slug,
string Name,
Guid? CurrentShowId,
string? CurrentShowName,
bool CurrentShowHasPoster
);
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
public sealed record PublicEpgEntryDto(
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
bool ShowHasPoster,
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,
bool EpisodeHasStill
);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
@@ -1,6 +1,6 @@
namespace TeleWave.Domain.Library;
/// <summary>Одна серия шоу — ссылка на медиа-ассет и позиция в порядке показа.</summary>
/// <summary>Одна серия шоу — ссылка на медиа-ассет и позиция в порядке показа + метаданные.</summary>
public class ShowEpisode
{
public Guid Id { get; private set; }
@@ -10,6 +10,18 @@ public class ShowEpisode
/// <summary>Порядковый номер внутри шоу (может иметь разрывы после удалений).</summary>
public int Position { get; private set; }
/// <summary>Распознанные сезон/серия (из имени файла) — база для довыгрузки метаданных.</summary>
public int? Season { get; private set; }
public int? Episode { get; private set; }
// ── Метаданные серии ──
public string? Title { get; private set; }
public string? Overview { get; private set; }
/// <summary>Относительный путь локального кадра или null.</summary>
public string? StillPath { get; private set; }
public DateOnly? AirDate { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
private ShowEpisode() { }
@@ -23,4 +35,29 @@ public class ShowEpisode
Position = position,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Проставить распознанные номера сезона/серии.</summary>
public void SetNumbers(int? season, int? episode)
{
Season = season;
Episode = episode;
}
/// <summary>Применить метаданные серии (кадр — уже скачанный локально — может быть null).</summary>
public void ApplyMetadata(string? title, string? overview, string? stillPath, DateOnly? airDate)
{
Title = title;
Overview = overview;
if (stillPath is not null)
StillPath = stillPath;
AirDate = airDate;
}
public void ClearMetadata()
{
Title = null;
Overview = null;
StillPath = null;
AirDate = null;
}
}
@@ -104,7 +104,8 @@ public sealed class FfmpegBumperRenderer(
var font = EscapePath(spec.FontFile);
var outStart = Math.Max(0, target - 1);
// Вход 0 — видеофон: загруженный файл (петля + масштаб/кроп) либо анимированный градиент.
// Вход 0 — видеофон: загруженный файл (петля + масштаб/кроп), постер шоу (затемнённый) либо
// анимированный градиент.
var inputs = new List<string>();
string videoPrefix;
if (!string.IsNullOrEmpty(spec.BackgroundFile))
@@ -117,6 +118,15 @@ public sealed class FfmpegBumperRenderer(
videoPrefix =
$"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},fps=30,format=yuv420p";
}
else if (!string.IsNullOrEmpty(spec.PosterFile))
{
// Постер (портрет) растягиваем на кадр, размываем и затемняем, чтобы текст читался.
inputs.AddRange(["-loop", "1", "-i", spec.PosterFile]);
videoPrefix =
$"[0:v]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
+ ",boxblur=6:1,eq=brightness=-0.28:saturation=0.9"
+ ",fps=30,format=yuv420p";
}
else
{
var gradient =
@@ -55,6 +55,17 @@ public sealed class MediaPathResolver
public string MetadataShowPosterRelative(Guid showId, string extension) =>
$"metadata/shows/{showId:N}/poster{extension}";
public string MetadataEpisodeDir(Guid episodeId) =>
EnsureWithinRoot(Path.Combine(MetadataDir, "episodes", episodeId.ToString("N")));
public string MetadataEpisodeStillPath(Guid episodeId, string extension) =>
EnsureWithinRoot(
Path.Combine(MetadataDir, "episodes", episodeId.ToString("N"), "still" + extension)
);
public string MetadataEpisodeStillRelative(Guid episodeId, string extension) =>
$"metadata/episodes/{episodeId:N}/still{extension}";
/// <summary>Абсолютный путь по относительному (с защитой от traversal) или null, если вне корня.</summary>
public string? ResolveRelative(string relativePath)
{
@@ -44,6 +44,43 @@ public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFacto
Directory.Delete(dir, recursive: true);
}
public async Task<string?> DownloadEpisodeStillAsync(
Guid episodeId,
string url,
CancellationToken cancellationToken
)
{
try
{
var client = httpFactory.CreateClient("metadata");
using var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var ext = ExtensionFor(url, response.Content.Headers.ContentType?.MediaType);
var dir = paths.MetadataEpisodeDir(episodeId);
Directory.CreateDirectory(dir);
RemoveExisting(dir, "still");
var abs = paths.MetadataEpisodeStillPath(episodeId, ext);
await using var content = await response.Content.ReadAsStreamAsync(cancellationToken);
await using (var fs = File.Create(abs))
await content.CopyToAsync(fs, cancellationToken);
return paths.MetadataEpisodeStillRelative(episodeId, ext);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
{
return null;
}
}
public void DeleteEpisodeImages(Guid episodeId)
{
var dir = paths.MetadataEpisodeDir(episodeId);
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
public string? ResolveAbsolutePath(string relativePath)
{
var abs = paths.ResolveRelative(relativePath);
@@ -0,0 +1,848 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeleWave.Infrastructure.Persistence;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260725061302_EpisodeMetadata")]
partial class EpisodeMetadata
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromShowId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");
b.ToTable("BumperAssets");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("AdInsertion")
.HasColumnType("integer");
b.Property<int>("AdsPerBreak")
.HasColumnType("integer");
b.Property<string>("BumperAccentColor")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperBackgroundColor")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperBackgroundColor2")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperBackgroundExtension")
.HasColumnType("text");
b.Property<int>("BumperDurationSeconds")
.HasColumnType("integer");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<int>("BumperMode")
.HasColumnType("integer");
b.Property<string>("BumperMusicExtension")
.HasColumnType("text");
b.Property<string>("BumperNextLabel")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperNowLabel")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumperOnlyBetweenDifferentShows")
.HasColumnType("boolean");
b.Property<int>("BumperRevision")
.HasColumnType("integer");
b.Property<string>("BumperTextColor")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("EpochUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("FillerAssetId")
.HasColumnType("uuid");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("NextAdIndex")
.HasColumnType("integer");
b.Property<int>("NextJingleIndex")
.HasColumnType("integer");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
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.ChannelJingle", 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("ChannelJingle");
});
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<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.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<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Mode")
.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")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EpisodeIndex")
.HasColumnType("integer");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("ShowId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("StartsAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ChannelId", "EndsAtUtc");
b.HasIndex("ChannelId", "ShowId");
b.HasIndex("ChannelId", "StartsAtUtc");
b.ToTable("ScheduleEntries");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", 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<int>("Kind")
.HasColumnType("integer");
b.Property<string>("MetadataExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MetadataProvider")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PosterPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateOnly?>("AirDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Episode")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Overview")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int?>("Season")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<string>("StillPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("MediaAssetId");
b.HasIndex("ShowId", "Position");
b.ToTable("ShowEpisode");
});
modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AudioCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("Height")
.HasColumnType("integer");
b.Property<string>("OriginalExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("SegmentCount")
.HasColumnType("integer");
b.Property<int?>("SegmentSeconds")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VideoCodec")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int?>("Width")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Key");
b.ToTable("AppSettings");
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Ads")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Jingles")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("Shows")
.HasForeignKey("ChannelId")
.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")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
{
b.HasOne("TeleWave.Domain.Library.Show", null)
.WithMany("Episodes")
.HasForeignKey("ShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Navigation("Ads");
b.Navigation("Jingles");
b.Navigation("Overrides");
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b =>
{
b.Navigation("Shows");
});
modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
{
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class EpisodeMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
name: "AirDate",
table: "ShowEpisode",
type: "date",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Episode",
table: "ShowEpisode",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Overview",
table: "ShowEpisode",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Season",
table: "ShowEpisode",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "StillPath",
table: "ShowEpisode",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Title",
table: "ShowEpisode",
type: "character varying(512)",
maxLength: 512,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AirDate",
table: "ShowEpisode");
migrationBuilder.DropColumn(
name: "Episode",
table: "ShowEpisode");
migrationBuilder.DropColumn(
name: "Overview",
table: "ShowEpisode");
migrationBuilder.DropColumn(
name: "Season",
table: "ShowEpisode");
migrationBuilder.DropColumn(
name: "StillPath",
table: "ShowEpisode");
migrationBuilder.DropColumn(
name: "Title",
table: "ShowEpisode");
}
}
}
@@ -488,18 +488,39 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateOnly?>("AirDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Episode")
.HasColumnType("integer");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<string>("Overview")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int?>("Season")
.HasColumnType("integer");
b.Property<Guid>("ShowId")
.HasColumnType("uuid");
b.Property<string>("StillPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("MediaAssetId");
@@ -30,5 +30,8 @@ public class ShowEpisodeConfiguration : IEntityTypeConfiguration<ShowEpisode>
{
builder.HasIndex(x => new { x.ShowId, x.Position });
builder.HasIndex(x => x.MediaAssetId);
builder.Property(x => x.Title).HasMaxLength(512);
builder.Property(x => x.Overview).HasMaxLength(4096);
builder.Property(x => x.StillPath).HasMaxLength(256);
}
}