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 Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Images.DeleteImage;
|
||||||
|
using TeleWave.Application.Images.UploadImage;
|
||||||
using TeleWave.Application.Metadata;
|
using TeleWave.Application.Metadata;
|
||||||
using TeleWave.Application.Metadata.ApplyShowMetadata;
|
using TeleWave.Application.Metadata.ApplyShowMetadata;
|
||||||
using TeleWave.Application.Metadata.ClearShowMetadata;
|
using TeleWave.Application.Metadata.ClearShowMetadata;
|
||||||
@@ -10,6 +12,7 @@ using TeleWave.Application.Metadata.RefreshEpisodes;
|
|||||||
using TeleWave.Application.Metadata.SearchShows;
|
using TeleWave.Application.Metadata.SearchShows;
|
||||||
using TeleWave.Application.Metadata.SetShowPoster;
|
using TeleWave.Application.Metadata.SetShowPoster;
|
||||||
using TeleWave.Application.Metadata.UpdateShowMetadata;
|
using TeleWave.Application.Metadata.UpdateShowMetadata;
|
||||||
|
using TeleWave.Domain.Images;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
@@ -40,12 +43,13 @@ public static class MetadataEndpoints
|
|||||||
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapDelete("/shows/{showId:guid}", Clear).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", 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>();
|
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
|
||||||
|
|
||||||
// Постеры/кадры отдаём публично (просто картинки, id не угадать) — чтобы работал <img src>.
|
// Кадры серий отдаём публично (просто картинки, id не угадать) — чтобы работал <img src>.
|
||||||
app.MapGet("/api/metadata/shows/{showId:guid}/poster", ServePoster)
|
// Постеры шоу теперь в общем реестре и отдаются по /api/images/{id}.
|
||||||
.WithTags("Metadata")
|
|
||||||
.Produces(StatusCodes.Status200OK);
|
|
||||||
app.MapGet("/api/metadata/episodes/{episodeId:guid}/still", ServeStill)
|
app.MapGet("/api/metadata/episodes/{episodeId:guid}/still", ServeStill)
|
||||||
.WithTags("Metadata")
|
.WithTags("Metadata")
|
||||||
.Produces(StatusCodes.Status200OK);
|
.Produces(StatusCodes.Status200OK);
|
||||||
@@ -112,7 +116,7 @@ public static class MetadataEndpoints
|
|||||||
Guid showId,
|
Guid showId,
|
||||||
string fileName,
|
string fileName,
|
||||||
HttpRequest request,
|
HttpRequest request,
|
||||||
IMetadataImageStore imageStore,
|
IImageStore imageStore,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
@@ -124,10 +128,44 @@ public static class MetadataEndpoints
|
|||||||
)
|
)
|
||||||
return MetadataErrors.InvalidPoster.ToProblem();
|
return MetadataErrors.InvalidPoster.ToProblem();
|
||||||
|
|
||||||
var relative = await imageStore.SaveShowPosterAsync(showId, ext, request.Body, cancellationToken);
|
// Регистрируем постер в общем реестре (категория ShowPoster) и привязываем к шоу.
|
||||||
var result = await sender.Send(new SetShowPosterCommand(showId, relative), cancellationToken);
|
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)
|
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();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,20 +182,6 @@ public static class MetadataEndpoints
|
|||||||
return result.ToHttpResult();
|
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(
|
private static async Task<IResult> ServeStill(
|
||||||
Guid episodeId,
|
Guid episodeId,
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
@@ -193,3 +217,5 @@ public static class MetadataEndpoints
|
|||||||
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
|
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
|
||||||
|
|
||||||
public sealed record UpdateMetadataBody(string? Description, int? Year);
|
public sealed record UpdateMetadataBody(string? Description, int? Year);
|
||||||
|
|
||||||
|
public sealed record SetPosterImageBody(Guid? ImageId);
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ var app = builder.Build();
|
|||||||
|
|
||||||
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
||||||
await app.Services.ApplyMigrationsAsync();
|
await app.Services.ApplyMigrationsAsync();
|
||||||
|
await app.Services.RelocateLegacyImagesAsync();
|
||||||
await app.Services.SeedDataAsync();
|
await app.Services.SeedDataAsync();
|
||||||
|
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public sealed class ScheduleGenerator(
|
|||||||
IRandomSource random,
|
IRandomSource random,
|
||||||
IBumperRenderer bumperRenderer,
|
IBumperRenderer bumperRenderer,
|
||||||
IBumperTemplateStorage bumperStorage,
|
IBumperTemplateStorage bumperStorage,
|
||||||
IMetadataImageStore metadataImages,
|
IImageStore imageStore,
|
||||||
IOptions<SchedulerOptions> options,
|
IOptions<SchedulerOptions> options,
|
||||||
IOptions<BumperOptions> bumperOptions,
|
IOptions<BumperOptions> bumperOptions,
|
||||||
IOptions<StreamingOptions> streamingOptions,
|
IOptions<StreamingOptions> streamingOptions,
|
||||||
@@ -196,12 +196,25 @@ public sealed class ScheduleGenerator(
|
|||||||
var fromIds = combos.Select(c => c.From).Distinct().ToList();
|
var fromIds = combos.Select(c => c.From).Distinct().ToList();
|
||||||
var toIds = combos.Select(c => c.To).Distinct().ToList();
|
var toIds = combos.Select(c => c.To).Distinct().ToList();
|
||||||
|
|
||||||
// Постеры шоу-получателей — как фон заставки (если у блока нет своей фон-картинки).
|
// Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет
|
||||||
|
// своей фон-картинки. Резолвим id постера → расширение → абсолютный путь.
|
||||||
var showIds = fromIds.Concat(toIds).Distinct().ToList();
|
var showIds = fromIds.Concat(toIds).Distinct().ToList();
|
||||||
var posterByShow = await dbContext.Shows.AsNoTracking()
|
var posterShows = await dbContext.Shows.AsNoTracking()
|
||||||
.Where(s => showIds.Contains(s.Id) && s.PosterPath != null)
|
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
|
||||||
.Select(s => new { s.Id, s.PosterPath })
|
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
|
||||||
.ToDictionaryAsync(s => s.Id, s => s.PosterPath!, cancellationToken);
|
.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 — файлы могли удалить).
|
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
|
||||||
var cached = await dbContext.BumperAssets.AsNoTracking()
|
var cached = await dbContext.BumperAssets.AsNoTracking()
|
||||||
@@ -229,9 +242,11 @@ public sealed class ScheduleGenerator(
|
|||||||
|
|
||||||
var fromName = showNames.GetValueOrDefault(combo.From, "…");
|
var fromName = showNames.GetValueOrDefault(combo.From, "…");
|
||||||
var toName = showNames.GetValueOrDefault(combo.To, "…");
|
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 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 =>
|
var hit = cached.FirstOrDefault(c =>
|
||||||
c.FromShowId == combo.From
|
c.FromShowId == combo.From
|
||||||
@@ -256,7 +271,7 @@ public sealed class ScheduleGenerator(
|
|||||||
toName,
|
toName,
|
||||||
aligned,
|
aligned,
|
||||||
signature,
|
signature,
|
||||||
toPosterRel,
|
posterAbs,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
result[combo] = assetId;
|
result[combo] = assetId;
|
||||||
@@ -284,14 +299,12 @@ public sealed class ScheduleGenerator(
|
|||||||
string toName,
|
string toName,
|
||||||
int alignedDurationSeconds,
|
int alignedDurationSeconds,
|
||||||
string signature,
|
string signature,
|
||||||
string? toPosterRelative,
|
string? posterAbsolutePath,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
||||||
var posterAbs = toPosterRelative is null
|
var posterAbs = posterAbsolutePath;
|
||||||
? null
|
|
||||||
: metadataImages.ResolveAbsolutePath(toPosterRelative);
|
|
||||||
var render = await bumperRenderer.RenderAsync(
|
var render = await bumperRenderer.RenderAsync(
|
||||||
asset.Id,
|
asset.Id,
|
||||||
BuildSpec(channel, template, alignedDurationSeconds, fromName, toName, posterAbs),
|
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>
|
/// </summary>
|
||||||
public interface IMetadataImageStore
|
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>
|
/// <summary>Скачивает кадр серии по URL. Возвращает относительный путь или null при ошибке.</summary>
|
||||||
Task<string?> DownloadEpisodeStillAsync(
|
Task<string?> DownloadEpisodeStillAsync(
|
||||||
Guid episodeId,
|
Guid episodeId,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
|||||||
show.MetadataProvider,
|
show.MetadataProvider,
|
||||||
show.MetadataExternalId,
|
show.MetadataExternalId,
|
||||||
show.Year,
|
show.Year,
|
||||||
show.PosterPath is not null,
|
show.PosterImageId,
|
||||||
episodeDtos
|
episodeDtos
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
|
|||||||
s.Episodes.Count,
|
s.Episodes.Count,
|
||||||
seasons,
|
seasons,
|
||||||
s.Year,
|
s.Year,
|
||||||
s.PosterPath is not null,
|
s.PosterImageId is not null,
|
||||||
s.CreatedAt
|
s.CreatedAt
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,6 @@ public sealed record ShowDto(
|
|||||||
string? MetadataProvider,
|
string? MetadataProvider,
|
||||||
string? MetadataExternalId,
|
string? MetadataExternalId,
|
||||||
int? Year,
|
int? Year,
|
||||||
bool HasPoster,
|
Guid? PosterImageId,
|
||||||
IReadOnlyList<EpisodeDto> Episodes
|
IReadOnlyList<EpisodeDto> Episodes
|
||||||
);
|
);
|
||||||
|
|||||||
+21
-8
@@ -3,13 +3,15 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
|
using TeleWave.Domain.Images;
|
||||||
|
|
||||||
namespace TeleWave.Application.Metadata.ApplyShowMetadata;
|
namespace TeleWave.Application.Metadata.ApplyShowMetadata;
|
||||||
|
|
||||||
public sealed class ApplyShowMetadataCommandHandler(
|
public sealed class ApplyShowMetadataCommandHandler(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IMetadataProviderResolver resolver,
|
IMetadataProviderResolver resolver,
|
||||||
IMetadataImageStore imageStore
|
IImageDownloader downloader,
|
||||||
|
IImageStore imageStore
|
||||||
) : ICommandHandler<ApplyShowMetadataCommand, Result>
|
) : ICommandHandler<ApplyShowMetadataCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
@@ -32,15 +34,26 @@ public sealed class ApplyShowMetadataCommandHandler(
|
|||||||
if (meta is null)
|
if (meta is null)
|
||||||
return Result.Failure(MetadataErrors.NotFound);
|
return Result.Failure(MetadataErrors.NotFound);
|
||||||
|
|
||||||
string? posterPath = null;
|
// Постер скачиваем и регистрируем в общем реестре изображений (галерея).
|
||||||
|
Guid? posterImageId = null;
|
||||||
if (!string.IsNullOrEmpty(meta.PosterUrl))
|
if (!string.IsNullOrEmpty(meta.PosterUrl))
|
||||||
posterPath = await imageStore.DownloadShowPosterAsync(
|
{
|
||||||
show.Id,
|
var downloaded = await downloader.DownloadAsync(meta.PosterUrl, cancellationToken);
|
||||||
meta.PosterUrl,
|
if (downloaded is not null)
|
||||||
cancellationToken
|
{
|
||||||
);
|
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();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-5
@@ -6,10 +6,8 @@ using TeleWave.Application.Library;
|
|||||||
|
|
||||||
namespace TeleWave.Application.Metadata.ClearShowMetadata;
|
namespace TeleWave.Application.Metadata.ClearShowMetadata;
|
||||||
|
|
||||||
public sealed class ClearShowMetadataCommandHandler(
|
public sealed class ClearShowMetadataCommandHandler(IAppDbContext dbContext)
|
||||||
IAppDbContext dbContext,
|
: ICommandHandler<ClearShowMetadataCommand, Result>
|
||||||
IMetadataImageStore imageStore
|
|
||||||
) : ICommandHandler<ClearShowMetadataCommand, Result>
|
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
ClearShowMetadataCommand command,
|
ClearShowMetadataCommand command,
|
||||||
@@ -23,7 +21,7 @@ public sealed class ClearShowMetadataCommandHandler(
|
|||||||
if (show is null)
|
if (show is null)
|
||||||
return Result.Failure(ShowErrors.NotFound);
|
return Result.Failure(ShowErrors.NotFound);
|
||||||
|
|
||||||
imageStore.DeleteShowImages(show.Id);
|
// Отвязываем постер; сама картинка остаётся в галерее (удаляется отдельно из неё).
|
||||||
show.ClearMetadata();
|
show.ClearMetadata();
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ using TeleWave.Application.Common.Models;
|
|||||||
|
|
||||||
namespace TeleWave.Application.Metadata.SetShowPoster;
|
namespace TeleWave.Application.Metadata.SetShowPoster;
|
||||||
|
|
||||||
/// <summary>Привязать к шоу загруженный вручную постер (файл уже сохранён хранилищем).</summary>
|
/// <summary>Привязать/снять постер шоу по ссылке на запись реестра изображений (null — отвязать).</summary>
|
||||||
public sealed record SetShowPosterCommand(Guid ShowId, string PosterPath) : ICommand<Result>;
|
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)
|
if (show is null)
|
||||||
return Result.Failure(ShowErrors.NotFound);
|
return Result.Failure(ShowErrors.NotFound);
|
||||||
|
|
||||||
show.SetPosterPath(command.PosterPath);
|
show.SetPosterImage(command.ImageId);
|
||||||
return Result.Success();
|
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 showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList();
|
||||||
var shows = await dbContext.Shows.AsNoTracking()
|
var shows = await dbContext.Shows.AsNoTracking()
|
||||||
.Where(s => showIds.Contains(s.Id))
|
.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);
|
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||||
|
|
||||||
// Метаданные серий: ключ — (шоу, ассет).
|
// Метаданные серий: ключ — (шоу, ассет).
|
||||||
@@ -78,7 +78,7 @@ public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
|
|||||||
e.EndsAtUtc,
|
e.EndsAtUtc,
|
||||||
e.ShowId,
|
e.ShowId,
|
||||||
show?.Name,
|
show?.Name,
|
||||||
show?.PosterPath is not null,
|
show?.PosterImageId,
|
||||||
episode?.Id,
|
episode?.Id,
|
||||||
episode?.Title,
|
episode?.Title,
|
||||||
episode?.Overview,
|
episode?.Overview,
|
||||||
|
|||||||
+2
-2
@@ -42,7 +42,7 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
|||||||
var showIds = currentShowByChannel.Values.Distinct().ToList();
|
var showIds = currentShowByChannel.Values.Distinct().ToList();
|
||||||
var shows = await dbContext.Shows.AsNoTracking()
|
var shows = await dbContext.Shows.AsNoTracking()
|
||||||
.Where(s => showIds.Contains(s.Id))
|
.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);
|
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||||
|
|
||||||
return channels
|
return channels
|
||||||
@@ -56,7 +56,7 @@ public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
|||||||
c.Name,
|
c.Name,
|
||||||
show is null ? null : showId,
|
show is null ? null : showId,
|
||||||
show?.Name,
|
show?.Name,
|
||||||
show?.PosterPath is not null
|
show?.PosterImageId
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public sealed record PublicChannelDto(
|
|||||||
string Name,
|
string Name,
|
||||||
Guid? CurrentShowId,
|
Guid? CurrentShowId,
|
||||||
string? CurrentShowName,
|
string? CurrentShowName,
|
||||||
bool CurrentShowHasPoster
|
Guid? CurrentShowPosterImageId
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
|
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
|
||||||
@@ -18,7 +18,7 @@ public sealed record PublicEpgEntryDto(
|
|||||||
DateTimeOffset EndsAtUtc,
|
DateTimeOffset EndsAtUtc,
|
||||||
Guid? ShowId,
|
Guid? ShowId,
|
||||||
string? ShowName,
|
string? ShowName,
|
||||||
bool ShowHasPoster,
|
Guid? ShowPosterImageId,
|
||||||
Guid? EpisodeId,
|
Guid? EpisodeId,
|
||||||
string? EpisodeTitle,
|
string? EpisodeTitle,
|
||||||
string? EpisodeOverview,
|
string? EpisodeOverview,
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ public class Show
|
|||||||
|
|
||||||
public int? Year { get; private set; }
|
public int? Year { get; private set; }
|
||||||
|
|
||||||
/// <summary>Относительный путь локального постера от корня хранилища или null.</summary>
|
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
|
||||||
public string? PosterPath { get; private set; }
|
public Guid? PosterImageId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
|
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
|
||||||
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
|
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
|
||||||
@@ -86,13 +86,13 @@ public class Show
|
|||||||
|
|
||||||
public bool CanAddEpisode => Kind != ShowKind.Single || _episodes.Count == 0;
|
public bool CanAddEpisode => Kind != ShowKind.Single || _episodes.Count == 0;
|
||||||
|
|
||||||
/// <summary>Применить метаданные из внешнего источника. Постер (уже скачанный локально) может быть null.</summary>
|
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
|
||||||
public void ApplyMetadata(
|
public void ApplyMetadata(
|
||||||
string provider,
|
string provider,
|
||||||
string externalId,
|
string externalId,
|
||||||
string? description,
|
string? description,
|
||||||
int? year,
|
int? year,
|
||||||
string? posterPath
|
Guid? posterImageId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
MetadataProvider = provider;
|
MetadataProvider = provider;
|
||||||
@@ -100,8 +100,8 @@ public class Show
|
|||||||
if (!string.IsNullOrWhiteSpace(description))
|
if (!string.IsNullOrWhiteSpace(description))
|
||||||
Description = description;
|
Description = description;
|
||||||
Year = year;
|
Year = year;
|
||||||
if (posterPath is not null)
|
if (posterImageId is not null)
|
||||||
PosterPath = posterPath;
|
PosterImageId = posterImageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
|
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
|
||||||
@@ -113,16 +113,16 @@ public class Show
|
|||||||
Year = year;
|
Year = year;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Задать/снять локальный постер (после скачивания/загрузки/удаления файла).</summary>
|
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
|
||||||
public void SetPosterPath(string? path) => PosterPath = path;
|
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
|
||||||
|
|
||||||
/// <summary>Сбросить все метаданные и постер (файл удаляет вызывающий по старому PosterPath).</summary>
|
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
|
||||||
public void ClearMetadata()
|
public void ClearMetadata()
|
||||||
{
|
{
|
||||||
MetadataProvider = null;
|
MetadataProvider = null;
|
||||||
MetadataExternalId = null;
|
MetadataExternalId = null;
|
||||||
Year = null;
|
Year = null;
|
||||||
PosterPath = null;
|
PosterImageId = null;
|
||||||
Description = null;
|
Description = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
|
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
|
||||||
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
|
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
|
||||||
services.AddSingleton<IImageStore, ImageStore>();
|
services.AddSingleton<IImageStore, ImageStore>();
|
||||||
|
services.AddSingleton<IImageDownloader, ImageDownloader>();
|
||||||
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
|
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
|
||||||
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
|
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
|
||||||
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
|
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)
|
public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFactory httpFactory)
|
||||||
: IMetadataImageStore
|
: 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(
|
public async Task<string?> DownloadEpisodeStillAsync(
|
||||||
Guid episodeId,
|
Guid episodeId,
|
||||||
string url,
|
string url,
|
||||||
@@ -87,24 +50,6 @@ public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFacto
|
|||||||
return abs is not null && File.Exists(abs) ? abs : null;
|
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)
|
private static void RemoveExisting(string dir, string baseName)
|
||||||
{
|
{
|
||||||
foreach (var file in Directory.EnumerateFiles(dir, baseName + ".*"))
|
foreach (var file in Directory.EnumerateFiles(dir, baseName + ".*"))
|
||||||
@@ -119,7 +64,4 @@ public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFacto
|
|||||||
"image/jpeg" => ".jpg",
|
"image/jpeg" => ".jpg",
|
||||||
_ => Path.GetExtension(new Uri(url).AbsolutePath) is { Length: > 1 } e ? e.ToLowerInvariant() : ".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)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
b.Property<string>("OriginalName")
|
b.Property<string>("OriginalName")
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("PosterPath")
|
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PosterImageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<int?>("Year")
|
b.Property<int?>("Year")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public class ShowConfiguration : IEntityTypeConfiguration<Show>
|
|||||||
builder.Property(x => x.Description).HasMaxLength(2048);
|
builder.Property(x => x.Description).HasMaxLength(2048);
|
||||||
builder.Property(x => x.MetadataProvider).HasMaxLength(16);
|
builder.Property(x => x.MetadataProvider).HasMaxLength(16);
|
||||||
builder.Property(x => x.MetadataExternalId).HasMaxLength(64);
|
builder.Property(x => x.MetadataExternalId).HasMaxLength(64);
|
||||||
builder.Property(x => x.PosterPath).HasMaxLength(256);
|
builder.Property(x => x.OriginalName).HasMaxLength(256);
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.HasMany(x => x.Episodes)
|
.HasMany(x => x.Episodes)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Persistence;
|
namespace TeleWave.Infrastructure.Persistence;
|
||||||
|
|
||||||
@@ -15,4 +16,47 @@ public static class MigrationExtensions
|
|||||||
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
await dbContext.Database.MigrateAsync(cancellationToken);
|
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 { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { imageUrl } from '@/features/admin/images/api'
|
||||||
|
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||||
import {
|
import {
|
||||||
applyMetadata,
|
applyMetadata,
|
||||||
clearMetadata,
|
clearMetadata,
|
||||||
@@ -16,7 +18,7 @@ import {
|
|||||||
refreshEpisodesMetadata,
|
refreshEpisodesMetadata,
|
||||||
searchMetadata,
|
searchMetadata,
|
||||||
setShowOriginalName,
|
setShowOriginalName,
|
||||||
showPosterUrl,
|
setShowPoster,
|
||||||
updateMetadata,
|
updateMetadata,
|
||||||
uploadPoster,
|
uploadPoster,
|
||||||
} from './api'
|
} from './api'
|
||||||
@@ -24,7 +26,7 @@ import {
|
|||||||
export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
|
export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const posterInput = useRef<HTMLInputElement>(null)
|
const posterInput = useRef<HTMLInputElement>(null)
|
||||||
const [bust, setBust] = useState(0)
|
const [galleryOpen, setGalleryOpen] = useState(false)
|
||||||
const [provider, setProvider] = useState('')
|
const [provider, setProvider] = useState('')
|
||||||
const [originalName, setOriginalName] = useState(show.originalName ?? '')
|
const [originalName, setOriginalName] = useState(show.originalName ?? '')
|
||||||
const [query, setQuery] = useState(show.originalName || show.name)
|
const [query, setQuery] = useState(show.originalName || show.name)
|
||||||
@@ -40,10 +42,16 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
|
|
||||||
const onError = (error: unknown) =>
|
const onError = (error: unknown) =>
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||||
const changed = () => {
|
const changed = () => onChanged()
|
||||||
setBust(Date.now())
|
|
||||||
onChanged()
|
const setPoster = useMutation({
|
||||||
}
|
mutationFn: (imageId: string) => setShowPoster(show.id, imageId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('settings.saved'))
|
||||||
|
changed()
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
})
|
||||||
|
|
||||||
const effectiveProvider = provider || providers?.[0] || ''
|
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 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">
|
<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
|
<img
|
||||||
src={showPosterUrl(show.id, String(bust))}
|
src={imageUrl(show.posterImageId)}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -150,6 +158,15 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
>
|
>
|
||||||
{t('admin.metadata.uploadPoster')}
|
{t('admin.metadata.uploadPoster')}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Поиск + ручная правка */}
|
{/* Поиск + ручная правка */}
|
||||||
@@ -282,7 +299,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
: t('admin.metadata.refreshEpisodes')}
|
: t('admin.metadata.refreshEpisodes')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(show.metadataProvider || show.hasPoster) && (
|
{(show.metadataProvider || show.posterImageId) && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -72,9 +72,12 @@ export function clearMetadata(showId: string) {
|
|||||||
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'DELETE' })
|
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ссылка на локальный постер шоу (публичный эндпоинт; cache-buster — по флагу наличия). */
|
/** Привязать/снять постер шоу по ссылке на изображение из реестра (null — отвязать). */
|
||||||
export function showPosterUrl(showId: string, bust?: string) {
|
export function setShowPoster(showId: string, imageId: string | null) {
|
||||||
return `/api/metadata/shows/${showId}/poster${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
|
return apiRequest<void>(`/admin/metadata/shows/${showId}/poster-image`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { imageId },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ссылка на локальный кадр серии. */
|
/** Ссылка на локальный кадр серии. */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cn } from '@/shared/lib/cn'
|
|||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { ChannelPlayer } from './ChannelPlayer'
|
import { ChannelPlayer } from './ChannelPlayer'
|
||||||
import { episodeStillUrl, getEpg, listChannels, showPosterUrl, watchChannel } from './api'
|
import { episodeStillUrl, getEpg, imageUrl, listChannels, watchChannel } from './api'
|
||||||
|
|
||||||
function formatTime(iso: string) {
|
function formatTime(iso: string) {
|
||||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
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',
|
selected === channel.slug && 'border-primary text-primary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{channel.currentShowHasPoster && channel.currentShowId ? (
|
{channel.currentShowPosterImageId ? (
|
||||||
<img
|
<img
|
||||||
src={showPosterUrl(channel.currentShowId)}
|
src={imageUrl(channel.currentShowPosterImageId)}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-10 w-7 shrink-0 rounded object-cover"
|
className="h-10 w-7 shrink-0 rounded object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -144,9 +144,9 @@ export function AirPage() {
|
|||||||
alt=""
|
alt=""
|
||||||
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
|
||||||
/>
|
/>
|
||||||
) : currentEntry?.showHasPoster && current.showId ? (
|
) : currentEntry?.showPosterImageId ? (
|
||||||
<img
|
<img
|
||||||
src={showPosterUrl(current.showId)}
|
src={imageUrl(currentEntry.showPosterImageId)}
|
||||||
alt=""
|
alt=""
|
||||||
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { apiRequest } from '@/shared/api/client'
|
import { apiRequest } from '@/shared/api/client'
|
||||||
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
|
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
|
||||||
|
|
||||||
/** Ссылки на локальные постер шоу / кадр серии (публичные эндпоинты метаданных). */
|
/** Ссылка на изображение общего реестра (постеры) — по id из публичных DTO. */
|
||||||
export function showPosterUrl(showId: string) {
|
export function imageUrl(imageId: string) {
|
||||||
return `/api/metadata/shows/${showId}/poster`
|
return `/api/images/${imageId}`
|
||||||
}
|
}
|
||||||
|
/** Кадр серии (пока по-старому — публичный эндпоинт метаданных). */
|
||||||
export function episodeStillUrl(episodeId: string) {
|
export function episodeStillUrl(episodeId: string) {
|
||||||
return `/api/metadata/episodes/${episodeId}/still`
|
return `/api/metadata/episodes/${episodeId}/still`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export type ShowDto = {
|
|||||||
metadataProvider: string | null
|
metadataProvider: string | null
|
||||||
metadataExternalId: string | null
|
metadataExternalId: string | null
|
||||||
year: number | null
|
year: number | null
|
||||||
hasPoster: boolean
|
posterImageId: string | null
|
||||||
episodes: EpisodeDto[]
|
episodes: EpisodeDto[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ export type PublicChannelDto = {
|
|||||||
name: string
|
name: string
|
||||||
currentShowId: string | null
|
currentShowId: string | null
|
||||||
currentShowName: string | null
|
currentShowName: string | null
|
||||||
currentShowHasPoster: boolean
|
currentShowPosterImageId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PublicEpgEntryDto = {
|
export type PublicEpgEntryDto = {
|
||||||
@@ -232,7 +232,7 @@ export type PublicEpgEntryDto = {
|
|||||||
endsAtUtc: string
|
endsAtUtc: string
|
||||||
showId: string | null
|
showId: string | null
|
||||||
showName: string | null
|
showName: string | null
|
||||||
showHasPoster: boolean
|
showPosterImageId: string | null
|
||||||
episodeId: string | null
|
episodeId: string | null
|
||||||
episodeTitle: string | null
|
episodeTitle: string | null
|
||||||
episodeOverview: string | null
|
episodeOverview: string | null
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ const resources = {
|
|||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
title: 'Метаданные',
|
title: 'Метаданные',
|
||||||
|
pickFromGallery: 'Выбрать из галереи',
|
||||||
originalName: 'Оригинальное название (eng)',
|
originalName: 'Оригинальное название (eng)',
|
||||||
originalNamePlaceholder: 'Например: Family Guy',
|
originalNamePlaceholder: 'Например: Family Guy',
|
||||||
originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.',
|
originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.',
|
||||||
@@ -584,6 +585,7 @@ const resources = {
|
|||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
title: 'Metadata',
|
title: 'Metadata',
|
||||||
|
pickFromGallery: 'Pick from gallery',
|
||||||
originalName: 'Original name (eng)',
|
originalName: 'Original name (eng)',
|
||||||
originalNamePlaceholder: 'e.g. Family Guy',
|
originalNamePlaceholder: 'e.g. Family Guy',
|
||||||
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',
|
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',
|
||||||
|
|||||||
Reference in New Issue
Block a user