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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
+21
-8
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -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>;
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+889
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user