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.RefreshEpisodes; 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 PosterExtensions = new HashSet( 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>(); admin.MapGet("/search", Search).Produces>(); 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); admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces(); // Постеры/кадры отдаём публично (просто картинки, id не угадать) — чтобы работал . app.MapGet("/api/metadata/shows/{showId:guid}/poster", ServePoster) .WithTags("Metadata") .Produces(StatusCodes.Status200OK); app.MapGet("/api/metadata/episodes/{episodeId:guid}/still", ServeStill) .WithTags("Metadata") .Produces(StatusCodes.Status200OK); return app; } private static async Task GetProviders(ISender sender, CancellationToken cancellationToken) { var providers = await sender.Send(new GetMetadataProvidersQuery(), cancellationToken); return Results.Ok(providers); } private static async Task 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 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 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 Clear( Guid showId, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send(new ClearShowMetadataCommand(showId), cancellationToken); return result.ToHttpResult(); } private static async Task 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 RefreshEpisodes( Guid showId, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( new RefreshShowEpisodesMetadataCommand(showId), cancellationToken ); return result.ToHttpResult(); } private static async Task 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 ServeStill( Guid episodeId, IAppDbContext dbContext, IMetadataImageStore imageStore, CancellationToken cancellationToken ) { var path = await dbContext.Shows.AsNoTracking() .SelectMany(s => s.Episodes) .Where(e => e.Id == episodeId) .Select(e => e.StillPath) .FirstOrDefaultAsync(cancellationToken); return ServeImage(path, imageStore); } private static IResult ServeImage(string? relativePath, IMetadataImageStore imageStore) { if (string.IsNullOrEmpty(relativePath)) return Results.NotFound(); var abs = imageStore.ResolveAbsolutePath(relativePath); return abs is null ? Results.NotFound() : Results.File(abs, ContentTypeFor(Path.GetExtension(abs))); } private static string ContentTypeFor(string extension) => 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);