Implement image management enhancements: add functionality to relocate legacy images during startup, update image handling in show metadata, and refactor related API endpoints to utilize the new image storage system. Adjust database schema to support image references and update UI components for improved image selection and display.

This commit is contained in:
Leonid Pershin
2026-07-25 11:43:17 +03:00
parent 149cd153b9
commit a970eae7d2
30 changed files with 1231 additions and 172 deletions
@@ -2,6 +2,8 @@ using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Images.DeleteImage;
using TeleWave.Application.Images.UploadImage;
using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.ApplyShowMetadata;
using TeleWave.Application.Metadata.ClearShowMetadata;
@@ -10,6 +12,7 @@ using TeleWave.Application.Metadata.RefreshEpisodes;
using TeleWave.Application.Metadata.SearchShows;
using TeleWave.Application.Metadata.SetShowPoster;
using TeleWave.Application.Metadata.UpdateShowMetadata;
using TeleWave.Domain.Images;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
@@ -40,12 +43,13 @@ 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
.MapPut("/shows/{showId:guid}/poster-image", SetPosterImage)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
// Постеры/кадры отдаём публично (просто картинки, id не угадать) — чтобы работал <img src>.
app.MapGet("/api/metadata/shows/{showId:guid}/poster", ServePoster)
.WithTags("Metadata")
.Produces(StatusCodes.Status200OK);
// Кадры серий отдаём публично (просто картинки, id не угадать) — чтобы работал <img src>.
// Постеры шоу теперь в общем реестре и отдаются по /api/images/{id}.
app.MapGet("/api/metadata/episodes/{episodeId:guid}/still", ServeStill)
.WithTags("Metadata")
.Produces(StatusCodes.Status200OK);
@@ -112,7 +116,7 @@ public static class MetadataEndpoints
Guid showId,
string fileName,
HttpRequest request,
IMetadataImageStore imageStore,
IImageStore imageStore,
ISender sender,
CancellationToken cancellationToken
)
@@ -124,10 +128,44 @@ public static class MetadataEndpoints
)
return MetadataErrors.InvalidPoster.ToProblem();
var relative = await imageStore.SaveShowPosterAsync(showId, ext, request.Body, cancellationToken);
var result = await sender.Send(new SetShowPosterCommand(showId, relative), cancellationToken);
// Регистрируем постер в общем реестре (категория ShowPoster) и привязываем к шоу.
var created = await sender.Send(
new UploadImageCommand(ImageCategory.ShowPoster, ext, fileName),
cancellationToken
);
if (!created.IsSuccess)
return created.ToHttpResult();
try
{
await imageStore.SaveAsync(created.Value, ext, request.Body, cancellationToken);
}
catch
{
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
throw;
}
var result = await sender.Send(
new SetShowPosterCommand(showId, created.Value),
cancellationToken
);
if (!result.IsSuccess)
imageStore.DeleteShowImages(showId);
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetPosterImage(
Guid showId,
SetPosterImageBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowPosterCommand(showId, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
@@ -144,20 +182,6 @@ public static class MetadataEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> ServePoster(
Guid showId,
IAppDbContext dbContext,
IMetadataImageStore imageStore,
CancellationToken cancellationToken
)
{
var path = await dbContext.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.PosterPath)
.FirstOrDefaultAsync(cancellationToken);
return ServeImage(path, imageStore);
}
private static async Task<IResult> ServeStill(
Guid episodeId,
IAppDbContext dbContext,
@@ -193,3 +217,5 @@ public static class MetadataEndpoints
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
public sealed record UpdateMetadataBody(string? Description, int? Year);
public sealed record SetPosterImageBody(Guid? ImageId);
+1
View File
@@ -93,6 +93,7 @@ var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.RelocateLegacyImagesAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
@@ -24,7 +24,7 @@ public sealed class ScheduleGenerator(
IRandomSource random,
IBumperRenderer bumperRenderer,
IBumperTemplateStorage bumperStorage,
IMetadataImageStore metadataImages,
IImageStore imageStore,
IOptions<SchedulerOptions> options,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions,
@@ -196,12 +196,25 @@ public sealed class ScheduleGenerator(
var fromIds = combos.Select(c => c.From).Distinct().ToList();
var toIds = combos.Select(c => c.To).Distinct().ToList();
// Постеры шоу-получателей — как фон заставки (если у блока нет своей фон-картинки).
// Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет
// своей фон-картинки. Резолвим id постера → расширение → абсолютный путь.
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);
var posterShows = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
.ToListAsync(cancellationToken);
var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList();
var posterExtById = await dbContext.Images.AsNoTracking()
.Where(i => posterImageIds.Contains(i.Id))
.Select(i => new { i.Id, i.FileExtension })
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
var posterByShow = new Dictionary<Guid, (Guid ImageId, string AbsPath)>();
foreach (var p in posterShows)
if (
posterExtById.TryGetValue(p.ImageId, out var ext)
&& imageStore.ResolvePath(p.ImageId, ext) is { } abs
)
posterByShow[p.Id] = (p.ImageId, abs);
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
var cached = await dbContext.BumperAssets.AsNoTracking()
@@ -229,9 +242,11 @@ public sealed class ScheduleGenerator(
var fromName = showNames.GetValueOrDefault(combo.From, "…");
var toName = showNames.GetValueOrDefault(combo.To, "…");
var toPosterRel = posterByShow.GetValueOrDefault(combo.To);
var poster = posterByShow.TryGetValue(combo.To, out var pr) ? pr : default;
var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString();
var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath;
var aligned = AlignedDurationSeconds(TemplateDurationSeconds(template));
var signature = ComputeSignature(channel, template, fromName, toName, aligned, toPosterRel ?? "-");
var signature = ComputeSignature(channel, template, fromName, toName, aligned, posterToken);
var hit = cached.FirstOrDefault(c =>
c.FromShowId == combo.From
@@ -256,7 +271,7 @@ public sealed class ScheduleGenerator(
toName,
aligned,
signature,
toPosterRel,
posterAbs,
cancellationToken
);
result[combo] = assetId;
@@ -284,14 +299,12 @@ public sealed class ScheduleGenerator(
string toName,
int alignedDurationSeconds,
string signature,
string? toPosterRelative,
string? posterAbsolutePath,
CancellationToken cancellationToken
)
{
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
var posterAbs = toPosterRelative is null
? null
: metadataImages.ResolveAbsolutePath(toPosterRelative);
var posterAbs = posterAbsolutePath;
var render = await bumperRenderer.RenderAsync(
asset.Id,
BuildSpec(channel, template, alignedDurationSeconds, fromName, toName, posterAbs),
@@ -0,0 +1,10 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Скачанное изображение: содержимое + расширение (с точкой, нижний регистр).</summary>
public sealed record DownloadedImage(byte[] Content, string Extension);
/// <summary>Скачивание изображения по URL (например постера/кадра из метаданных) для реестра.</summary>
public interface IImageDownloader
{
Task<DownloadedImage?> DownloadAsync(string url, CancellationToken cancellationToken);
}
@@ -6,23 +6,6 @@ namespace TeleWave.Application.Common.Interfaces;
/// </summary>
public interface IMetadataImageStore
{
/// <summary>Скачивает постер по URL и сохраняет для шоу. Возвращает относительный путь или null при ошибке.</summary>
Task<string?> DownloadShowPosterAsync(
Guid showId,
string url,
CancellationToken cancellationToken
);
/// <summary>Сохраняет загруженный вручную постер шоу. Возвращает относительный путь.</summary>
Task<string> SaveShowPosterAsync(
Guid showId,
string extension,
Stream content,
CancellationToken cancellationToken
);
void DeleteShowImages(Guid showId);
/// <summary>Скачивает кадр серии по URL. Возвращает относительный путь или null при ошибке.</summary>
Task<string?> DownloadEpisodeStillAsync(
Guid episodeId,
@@ -60,7 +60,7 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
show.MetadataProvider,
show.MetadataExternalId,
show.Year,
show.PosterPath is not null,
show.PosterImageId,
episodeDtos
)
);
@@ -39,7 +39,7 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
s.Episodes.Count,
seasons,
s.Year,
s.PosterPath is not null,
s.PosterImageId is not null,
s.CreatedAt
);
})
@@ -38,6 +38,6 @@ public sealed record ShowDto(
string? MetadataProvider,
string? MetadataExternalId,
int? Year,
bool HasPoster,
Guid? PosterImageId,
IReadOnlyList<EpisodeDto> Episodes
);
@@ -3,13 +3,15 @@ using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Library;
using TeleWave.Domain.Images;
namespace TeleWave.Application.Metadata.ApplyShowMetadata;
public sealed class ApplyShowMetadataCommandHandler(
IAppDbContext dbContext,
IMetadataProviderResolver resolver,
IMetadataImageStore imageStore
IImageDownloader downloader,
IImageStore imageStore
) : ICommandHandler<ApplyShowMetadataCommand, Result>
{
public async Task<Result> Handle(
@@ -32,15 +34,26 @@ public sealed class ApplyShowMetadataCommandHandler(
if (meta is null)
return Result.Failure(MetadataErrors.NotFound);
string? posterPath = null;
// Постер скачиваем и регистрируем в общем реестре изображений (галерея).
Guid? posterImageId = null;
if (!string.IsNullOrEmpty(meta.PosterUrl))
posterPath = await imageStore.DownloadShowPosterAsync(
show.Id,
meta.PosterUrl,
cancellationToken
);
{
var downloaded = await downloader.DownloadAsync(meta.PosterUrl, cancellationToken);
if (downloaded is not null)
{
var image = Image.Create(ImageCategory.ShowPoster, downloaded.Extension, show.Name);
dbContext.Images.Add(image);
await imageStore.SaveAsync(
image.Id,
downloaded.Extension,
downloaded.Content,
cancellationToken
);
posterImageId = image.Id;
}
}
show.ApplyMetadata(command.Provider, meta.ExternalId, meta.Overview, meta.Year, posterPath);
show.ApplyMetadata(command.Provider, meta.ExternalId, meta.Overview, meta.Year, posterImageId);
return Result.Success();
}
}
@@ -6,10 +6,8 @@ using TeleWave.Application.Library;
namespace TeleWave.Application.Metadata.ClearShowMetadata;
public sealed class ClearShowMetadataCommandHandler(
IAppDbContext dbContext,
IMetadataImageStore imageStore
) : ICommandHandler<ClearShowMetadataCommand, Result>
public sealed class ClearShowMetadataCommandHandler(IAppDbContext dbContext)
: ICommandHandler<ClearShowMetadataCommand, Result>
{
public async Task<Result> Handle(
ClearShowMetadataCommand command,
@@ -23,7 +21,7 @@ public sealed class ClearShowMetadataCommandHandler(
if (show is null)
return Result.Failure(ShowErrors.NotFound);
imageStore.DeleteShowImages(show.Id);
// Отвязываем постер; сама картинка остаётся в галерее (удаляется отдельно из неё).
show.ClearMetadata();
return Result.Success();
}
@@ -3,5 +3,5 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Metadata.SetShowPoster;
/// <summary>Привязать к шоу загруженный вручную постер (файл уже сохранён хранилищем).</summary>
public sealed record SetShowPosterCommand(Guid ShowId, string PosterPath) : ICommand<Result>;
/// <summary>Привязать/снять постер шоу по ссылке на запись реестра изображений (null — отвязать).</summary>
public sealed record SetShowPosterCommand(Guid ShowId, Guid? ImageId) : ICommand<Result>;
@@ -21,7 +21,7 @@ public sealed class SetShowPosterCommandHandler(IAppDbContext dbContext)
if (show is null)
return Result.Failure(ShowErrors.NotFound);
show.SetPosterPath(command.PosterPath);
show.SetPosterImage(command.ImageId);
return Result.Success();
}
}
@@ -41,7 +41,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
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 })
.Select(s => new { s.Id, s.Name, s.PosterImageId })
.ToDictionaryAsync(s => s.Id, cancellationToken);
// Метаданные серий: ключ — (шоу, ассет).
@@ -78,7 +78,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
e.EndsAtUtc,
e.ShowId,
show?.Name,
show?.PosterPath is not null,
show?.PosterImageId,
episode?.Id,
episode?.Title,
episode?.Overview,
@@ -42,7 +42,7 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
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 })
.Select(s => new { s.Id, s.Name, s.PosterImageId })
.ToDictionaryAsync(s => s.Id, cancellationToken);
return channels
@@ -56,7 +56,7 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
c.Name,
show is null ? null : showId,
show?.Name,
show?.PosterPath is not null
show?.PosterImageId
);
})
.ToList();
@@ -8,7 +8,7 @@ public sealed record PublicChannelDto(
string Name,
Guid? CurrentShowId,
string? CurrentShowName,
bool CurrentShowHasPoster
Guid? CurrentShowPosterImageId
);
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
@@ -18,7 +18,7 @@ public sealed record PublicEpgEntryDto(
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
bool ShowHasPoster,
Guid? ShowPosterImageId,
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,
+10 -10
View File
@@ -29,8 +29,8 @@ public class Show
public int? Year { get; private set; }
/// <summary>Относительный путь локального постера от корня хранилища или null.</summary>
public string? PosterPath { get; private set; }
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
public Guid? PosterImageId { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
@@ -86,13 +86,13 @@ public class Show
public bool CanAddEpisode => Kind != ShowKind.Single || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже скачанный локально) может быть null.</summary>
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata(
string provider,
string externalId,
string? description,
int? year,
string? posterPath
Guid? posterImageId
)
{
MetadataProvider = provider;
@@ -100,8 +100,8 @@ public class Show
if (!string.IsNullOrWhiteSpace(description))
Description = description;
Year = year;
if (posterPath is not null)
PosterPath = posterPath;
if (posterImageId is not null)
PosterImageId = posterImageId;
}
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
@@ -113,16 +113,16 @@ public class Show
Year = year;
}
/// <summary>Задать/снять локальный постер (после скачивания/загрузки/удаления файла).</summary>
public void SetPosterPath(string? path) => PosterPath = path;
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
/// <summary>Сбросить все метаданные и постер (файл удаляет вызывающий по старому PosterPath).</summary>
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
public void ClearMetadata()
{
MetadataProvider = null;
MetadataExternalId = null;
Year = null;
PosterPath = null;
PosterImageId = null;
Description = null;
}
}
@@ -137,6 +137,7 @@ public static class DependencyInjection
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>();
@@ -0,0 +1,38 @@
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Media;
/// <summary>Скачивает изображение по URL через HTTP-клиент «metadata» и определяет расширение.</summary>
public sealed class ImageDownloader(IHttpClientFactory httpFactory) : IImageDownloader
{
public async Task<DownloadedImage?> DownloadAsync(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 bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
return bytes.Length == 0 ? null : new DownloadedImage(bytes, ext);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
{
return null;
}
}
private static string ExtensionFor(string url, string? mediaType) =>
mediaType switch
{
"image/png" => ".png",
"image/webp" => ".webp",
"image/gif" => ".gif",
"image/jpeg" => ".jpg",
_ => Path.GetExtension(new Uri(url).AbsolutePath) is { Length: > 1 } e
? e.ToLowerInvariant()
: ".jpg",
};
}
@@ -7,43 +7,6 @@ namespace TeleWave.Infrastructure.Metadata;
public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFactory httpFactory)
: IMetadataImageStore
{
public async Task<string?> DownloadShowPosterAsync(
Guid showId,
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);
await using var content = await response.Content.ReadAsStreamAsync(cancellationToken);
return await WritePosterAsync(showId, ext, content, cancellationToken);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
{
return null;
}
}
public Task<string> SaveShowPosterAsync(
Guid showId,
string extension,
Stream content,
CancellationToken cancellationToken
) => WritePosterAsync(showId, NormalizeExtension(extension), content, cancellationToken);
public void DeleteShowImages(Guid showId)
{
var dir = paths.MetadataShowDir(showId);
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
public async Task<string?> DownloadEpisodeStillAsync(
Guid episodeId,
string url,
@@ -87,24 +50,6 @@ public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFacto
return abs is not null && File.Exists(abs) ? abs : null;
}
private async Task<string> WritePosterAsync(
Guid showId,
string ext,
Stream content,
CancellationToken cancellationToken
)
{
var dir = paths.MetadataShowDir(showId);
Directory.CreateDirectory(dir);
RemoveExisting(dir, "poster");
var abs = paths.MetadataShowPosterPath(showId, ext);
await using (var fs = File.Create(abs))
await content.CopyToAsync(fs, cancellationToken);
return paths.MetadataShowPosterRelative(showId, ext);
}
private static void RemoveExisting(string dir, string baseName)
{
foreach (var file in Directory.EnumerateFiles(dir, baseName + ".*"))
@@ -119,7 +64,4 @@ public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFacto
"image/jpeg" => ".jpg",
_ => Path.GetExtension(new Uri(url).AbsolutePath) is { Length: > 1 } e ? e.ToLowerInvariant() : ".jpg",
};
private static string NormalizeExtension(string extension) =>
extension.StartsWith('.') ? extension.ToLowerInvariant() : "." + extension.ToLowerInvariant();
}
@@ -0,0 +1,889 @@
// <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("20260725083827_ShowPosterImage")]
partial class ShowPosterImage
{
/// <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.BumperTemplate", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<double?>("AudioDurationSeconds")
.HasColumnType("double precision");
b.Property<string>("AudioExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("BackgroundColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundColor2")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("BackgroundImageExtension")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
b.Property<string>("TextColor")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.ToTable("BumperTemplate");
});
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<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperMinIntervalMinutes")
.HasColumnType("integer");
b.Property<string>("BumperNextLabel")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BumperNowLabel")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("BumperOnlyBetweenDifferentShows")
.HasColumnType("boolean");
b.Property<int>("BumperSelection")
.HasColumnType("integer");
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>("NextBumperIndex")
.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.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.Images.Image", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileExtension")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("OriginalFileName")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Id");
b.HasIndex("Category", "CreatedAt");
b.ToTable("Images");
});
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>("OriginalName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("PosterImageId")
.HasColumnType("uuid");
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.BumperTemplate", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("BumperTemplates")
.HasForeignKey("ChannelId")
.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.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("BumperTemplates");
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,78 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowPosterImage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "OriginalName",
table: "Shows",
type: "character varying(256)",
maxLength: 256,
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AddColumn<Guid>(
name: "PosterImageId",
table: "Shows",
type: "uuid",
nullable: true);
// Переносим существующие постеры шоу в общий реестр изображений: на каждый постер —
// запись Images (Category=1 ShowPoster) с расширением из старого пути; файлы перекладывает
// startup-шаг RelocateLegacyImagesAsync. Затем удаляем колонку PosterPath.
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD; img uuid;
BEGIN
FOR r IN SELECT "Id", "PosterPath" FROM "Shows" WHERE "PosterPath" IS NOT NULL LOOP
img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 1, lower(coalesce(substring(r."PosterPath" from '\.[^.]*$'), '.jpg')), 'poster', now());
UPDATE "Shows" SET "PosterImageId" = img WHERE "Id" = r."Id";
END LOOP;
END $$;
"""
);
migrationBuilder.DropColumn(
name: "PosterPath",
table: "Shows");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PosterImageId",
table: "Shows");
migrationBuilder.AlterColumn<string>(
name: "OriginalName",
table: "Shows",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256,
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "PosterPath",
table: "Shows",
type: "character varying(256)",
maxLength: 256,
nullable: true);
}
}
}
@@ -510,12 +510,12 @@ namespace TeleWave.Infrastructure.Migrations
.HasColumnType("character varying(256)");
b.Property<string>("OriginalName")
.HasColumnType("text");
b.Property<string>("PosterPath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("PosterImageId")
.HasColumnType("uuid");
b.Property<int?>("Year")
.HasColumnType("integer");
@@ -13,7 +13,7 @@ public class ShowConfiguration : IEntityTypeConfiguration<Show>
builder.Property(x => x.Description).HasMaxLength(2048);
builder.Property(x => x.MetadataProvider).HasMaxLength(16);
builder.Property(x => x.MetadataExternalId).HasMaxLength(64);
builder.Property(x => x.PosterPath).HasMaxLength(256);
builder.Property(x => x.OriginalName).HasMaxLength(256);
builder
.HasMany(x => x.Episodes)
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Infrastructure.Persistence;
@@ -15,4 +16,47 @@ public static class MigrationExtensions
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync(cancellationToken);
}
/// <summary>
/// Идемпотентно переносит файлы постеров шоу, мигрированных в реестр изображений, из старого
/// расположения metadata/shows/{showId}/poster{ext} в images/{imageId}{ext}. Безопасно к повторным
/// запускам (пропускает, если целевой файл уже на месте).
/// </summary>
public static async Task RelocateLegacyImagesAsync(
this IServiceProvider services,
CancellationToken cancellationToken = default
)
{
await using var scope = services.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var paths = scope.ServiceProvider.GetRequiredService<MediaPathResolver>();
var posters = await dbContext.Shows.AsNoTracking()
.Where(s => s.PosterImageId != null)
.Join(
dbContext.Images,
s => s.PosterImageId,
i => i.Id,
(s, i) => new
{
ShowId = s.Id,
ImageId = i.Id,
i.FileExtension,
}
)
.ToListAsync(cancellationToken);
foreach (var p in posters)
{
var target = paths.ImagePath(p.ImageId, p.FileExtension);
if (File.Exists(target))
continue;
var legacy = paths.MetadataShowPosterPath(p.ShowId, p.FileExtension);
if (!File.Exists(legacy))
continue;
Directory.CreateDirectory(paths.ImagesDir);
File.Move(legacy, target);
}
}
}
@@ -9,6 +9,8 @@ import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import {
applyMetadata,
clearMetadata,
@@ -16,7 +18,7 @@ import {
refreshEpisodesMetadata,
searchMetadata,
setShowOriginalName,
showPosterUrl,
setShowPoster,
updateMetadata,
uploadPoster,
} from './api'
@@ -24,7 +26,7 @@ import {
export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
const { t } = useTranslation()
const posterInput = useRef<HTMLInputElement>(null)
const [bust, setBust] = useState(0)
const [galleryOpen, setGalleryOpen] = useState(false)
const [provider, setProvider] = useState('')
const [originalName, setOriginalName] = useState(show.originalName ?? '')
const [query, setQuery] = useState(show.originalName || show.name)
@@ -40,10 +42,16 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const changed = () => {
setBust(Date.now())
onChanged()
}
const changed = () => onChanged()
const setPoster = useMutation({
mutationFn: (imageId: string) => setShowPoster(show.id, imageId),
onSuccess: () => {
toast.success(t('settings.saved'))
changed()
},
onError,
})
const effectiveProvider = provider || providers?.[0] || ''
@@ -121,9 +129,9 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
{/* Постер */}
<div className="flex w-40 shrink-0 flex-col gap-2">
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
{show.hasPoster ? (
{show.posterImageId ? (
<img
src={showPosterUrl(show.id, String(bust))}
src={imageUrl(show.posterImageId)}
alt=""
className="h-full w-full object-cover"
/>
@@ -150,6 +158,15 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
>
{t('admin.metadata.uploadPoster')}
</Button>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.metadata.pickFromGallery')}
</Button>
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="ShowPoster"
onSelect={(img) => setPoster.mutate(img.id)}
/>
</div>
{/* Поиск + ручная правка */}
@@ -282,7 +299,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
: t('admin.metadata.refreshEpisodes')}
</Button>
)}
{(show.metadataProvider || show.hasPoster) && (
{(show.metadataProvider || show.posterImageId) && (
<Button
size="sm"
variant="ghost"
+6 -3
View File
@@ -72,9 +72,12 @@ export function clearMetadata(showId: string) {
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'DELETE' })
}
/** Ссылка на локальный постер шоу (публичный эндпоинт; cache-buster — по флагу наличия). */
export function showPosterUrl(showId: string, bust?: string) {
return `/api/metadata/shows/${showId}/poster${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
/** Привязать/снять постер шоу по ссылке на изображение из реестра (null — отвязать). */
export function setShowPoster(showId: string, imageId: string | null) {
return apiRequest<void>(`/admin/metadata/shows/${showId}/poster-image`, {
method: 'PUT',
body: { imageId },
})
}
/** Ссылка на локальный кадр серии. */
+5 -5
View File
@@ -7,7 +7,7 @@ import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { ChannelPlayer } from './ChannelPlayer'
import { episodeStillUrl, getEpg, listChannels, showPosterUrl, watchChannel } from './api'
import { episodeStillUrl, getEpg, imageUrl, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -89,9 +89,9 @@ export function AirPage() {
selected === channel.slug && 'border-primary text-primary',
)}
>
{channel.currentShowHasPoster && channel.currentShowId ? (
{channel.currentShowPosterImageId ? (
<img
src={showPosterUrl(channel.currentShowId)}
src={imageUrl(channel.currentShowPosterImageId)}
alt=""
className="h-10 w-7 shrink-0 rounded object-cover"
/>
@@ -144,9 +144,9 @@ export function AirPage() {
alt=""
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
/>
) : currentEntry?.showHasPoster && current.showId ? (
) : currentEntry?.showPosterImageId ? (
<img
src={showPosterUrl(current.showId)}
src={imageUrl(currentEntry.showPosterImageId)}
alt=""
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
/>
+4 -3
View File
@@ -1,10 +1,11 @@
import { apiRequest } from '@/shared/api/client'
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
/** Ссылки на локальные постер шоу / кадр серии (публичные эндпоинты метаданных). */
export function showPosterUrl(showId: string) {
return `/api/metadata/shows/${showId}/poster`
/** Ссылка на изображение общего реестра (постеры) — по id из публичных DTO. */
export function imageUrl(imageId: string) {
return `/api/images/${imageId}`
}
/** Кадр серии (пока по-старому — публичный эндпоинт метаданных). */
export function episodeStillUrl(episodeId: string) {
return `/api/metadata/episodes/${episodeId}/still`
}
+3 -3
View File
@@ -117,7 +117,7 @@ export type ShowDto = {
metadataProvider: string | null
metadataExternalId: string | null
year: number | null
hasPoster: boolean
posterImageId: string | null
episodes: EpisodeDto[]
}
@@ -223,7 +223,7 @@ export type PublicChannelDto = {
name: string
currentShowId: string | null
currentShowName: string | null
currentShowHasPoster: boolean
currentShowPosterImageId: string | null
}
export type PublicEpgEntryDto = {
@@ -232,7 +232,7 @@ export type PublicEpgEntryDto = {
endsAtUtc: string
showId: string | null
showName: string | null
showHasPoster: boolean
showPosterImageId: string | null
episodeId: string | null
episodeTitle: string | null
episodeOverview: string | null
+2
View File
@@ -282,6 +282,7 @@ const resources = {
},
metadata: {
title: 'Метаданные',
pickFromGallery: 'Выбрать из галереи',
originalName: 'Оригинальное название (eng)',
originalNamePlaceholder: 'Например: Family Guy',
originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.',
@@ -584,6 +585,7 @@ const resources = {
},
metadata: {
title: 'Metadata',
pickFromGallery: 'Pick from gallery',
originalName: 'Original name (eng)',
originalNamePlaceholder: 'e.g. Family Guy',
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',