Add metadata management for shows: implement metadata retrieval, application, and updates in the API and UI. Enhance Show and ShowDto models to include metadata fields, and update the database schema accordingly. Introduce new endpoints for metadata operations and integrate metadata display in the ShowDetail component.

This commit is contained in:
Leonid Pershin
2026-07-25 09:07:55 +03:00
parent ba023bc416
commit 0d2dee815e
42 changed files with 2271 additions and 4 deletions
@@ -0,0 +1,161 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.ApplyShowMetadata;
using TeleWave.Application.Metadata.ClearShowMetadata;
using TeleWave.Application.Metadata.GetProviders;
using TeleWave.Application.Metadata.SearchShows;
using TeleWave.Application.Metadata.SetShowPoster;
using TeleWave.Application.Metadata.UpdateShowMetadata;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class MetadataEndpoints
{
private const long MaxPosterBytes = 10L * 1024 * 1024; // 10 МБ
private static readonly IReadOnlySet<string> PosterExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".jpg",
".jpeg",
".png",
".webp",
};
public static IEndpointRouteBuilder MapMetadataEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/metadata")
.WithTags("Admin.Metadata")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/providers", GetProviders).Produces<IReadOnlyList<string>>();
admin.MapGet("/search", Search).Produces<IReadOnlyList<MetadataCandidate>>();
admin.MapPost("/shows/{showId:guid}/apply", Apply).Produces(StatusCodes.Status204NoContent);
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);
// Постеры отдаём публично (просто картинки, id не угадать) — чтобы работал <img src>.
app.MapGet("/api/metadata/shows/{showId:guid}/poster", ServePoster)
.WithTags("Metadata")
.Produces(StatusCodes.Status200OK);
return app;
}
private static async Task<IResult> GetProviders(ISender sender, CancellationToken cancellationToken)
{
var providers = await sender.Send(new GetMetadataProvidersQuery(), cancellationToken);
return Results.Ok(providers);
}
private static async Task<IResult> Search(
string provider,
string query,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SearchShowMetadataQuery(provider, query), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Apply(
Guid showId,
ApplyMetadataBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyShowMetadataCommand(showId, body.Provider, body.ExternalId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Update(
Guid showId,
UpdateMetadataBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateShowMetadataCommand(showId, body.Description, body.Year),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Clear(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearShowMetadataCommand(showId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UploadPoster(
Guid showId,
string fileName,
HttpRequest request,
IMetadataImageStore imageStore,
ISender sender,
CancellationToken cancellationToken
)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (
request.ContentLength is > MaxPosterBytes or 0 or null
|| !PosterExtensions.Contains(ext)
)
return MetadataErrors.InvalidPoster.ToProblem();
var relative = await imageStore.SaveShowPosterAsync(showId, ext, request.Body, cancellationToken);
var result = await sender.Send(new SetShowPosterCommand(showId, relative), cancellationToken);
if (!result.IsSuccess)
imageStore.DeleteShowImages(showId);
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);
if (string.IsNullOrEmpty(path))
return Results.NotFound();
var abs = imageStore.ResolveAbsolutePath(path);
if (abs is null)
return Results.NotFound();
return Results.File(abs, ContentTypeFor(Path.GetExtension(abs)));
}
private static string ContentTypeFor(string extension) =>
extension.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
_ => "image/jpeg",
};
}
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
public sealed record UpdateMetadataBody(string? Description, int? Year);
+1
View File
@@ -118,6 +118,7 @@ app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
+14
View File
@@ -41,6 +41,20 @@
"FontFileSerif": "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
"TemplateVersion": 1
},
"Metadata": {
"Language": "ru-RU",
"Tmdb": {
"ApiKey": "",
"BaseUrl": "https://api.themoviedb.org/3",
"ImageBaseUrl": "https://image.tmdb.org/t/p",
"PosterSize": "w500",
"StillSize": "w300"
},
"Omdb": {
"ApiKey": "",
"BaseUrl": "https://www.omdbapi.com"
}
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {