From 0d2dee815e476c78fcbfb115dc40db2a60bd47d4 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 09:07:55 +0300 Subject: [PATCH] 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. --- .env.example | 7 + backend/Directory.Packages.props | 1 + .../Endpoints/MetadataEndpoints.cs | 161 ++++ backend/src/TeleWave.Api/Program.cs | 1 + backend/src/TeleWave.Api/appsettings.json | 14 + .../Common/Interfaces/IMetadataImageStore.cs | 28 + .../Common/Interfaces/IMetadataProvider.cs | 33 + .../Library/GetShow/GetShowQueryHandler.cs | 12 +- .../ListShows/ListShowsQueryHandler.cs | 11 +- .../TeleWave.Application/Library/ShowDtos.cs | 6 + .../ApplyShowMetadataCommand.cs | 8 + .../ApplyShowMetadataCommandHandler.cs | 46 + .../ClearShowMetadataCommand.cs | 6 + .../ClearShowMetadataCommandHandler.cs | 30 + .../GetProviders/GetMetadataProvidersQuery.cs | 6 + .../GetMetadataProvidersQueryHandler.cs | 13 + .../Metadata/MetadataErrors.cs | 21 + .../Metadata/MetadataModels.cs | 27 + .../SearchShows/SearchShowMetadataQuery.cs | 7 + .../SearchShowMetadataQueryHandler.cs | 25 + .../SetShowPoster/SetShowPosterCommand.cs | 7 + .../SetShowPosterCommandHandler.cs | 27 + .../UpdateShowMetadataCommand.cs | 8 + .../UpdateShowMetadataCommandHandler.cs | 27 + backend/src/TeleWave.Domain/Library/Show.cs | 52 ++ .../DependencyInjection.cs | 13 + .../Media/MediaPathResolver.cs | 31 + .../Metadata/MetadataImageStore.cs | 88 ++ .../Metadata/MetadataOptions.cs | 27 + .../Metadata/MetadataProviderResolver.cs | 32 + .../Metadata/OmdbMetadataProvider.cs | 122 +++ .../Metadata/TmdbMetadataProvider.cs | 122 +++ .../20260725055823_ShowMetadata.Designer.cs | 827 ++++++++++++++++++ .../Migrations/20260725055823_ShowMetadata.cs | 61 ++ .../Migrations/AppDbContextModelSnapshot.cs | 15 + .../Configurations/ShowConfiguration.cs | 3 + .../TeleWave.Infrastructure.csproj | 1 + .../src/features/admin/shows/ShowDetail.tsx | 3 + .../features/admin/shows/ShowMetadataCard.tsx | 237 +++++ frontend/src/features/admin/shows/api.ts | 67 +- frontend/src/shared/api/types.ts | 14 + frontend/src/shared/lib/i18n.ts | 28 + 42 files changed, 2271 insertions(+), 4 deletions(-) create mode 100644 backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs create mode 100644 backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs create mode 100644 backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommand.cs create mode 100644 backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommand.cs create mode 100644 backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Metadata/GetProviders/GetMetadataProvidersQuery.cs create mode 100644 backend/src/TeleWave.Application/Metadata/GetProviders/GetMetadataProvidersQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Metadata/MetadataErrors.cs create mode 100644 backend/src/TeleWave.Application/Metadata/MetadataModels.cs create mode 100644 backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQuery.cs create mode 100644 backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs create mode 100644 backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommand.cs create mode 100644 backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommandHandler.cs create mode 100644 backend/src/TeleWave.Application/Metadata/UpdateShowMetadata/UpdateShowMetadataCommand.cs create mode 100644 backend/src/TeleWave.Application/Metadata/UpdateShowMetadata/UpdateShowMetadataCommandHandler.cs create mode 100644 backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs create mode 100644 backend/src/TeleWave.Infrastructure/Metadata/MetadataOptions.cs create mode 100644 backend/src/TeleWave.Infrastructure/Metadata/MetadataProviderResolver.cs create mode 100644 backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs create mode 100644 backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725055823_ShowMetadata.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725055823_ShowMetadata.cs create mode 100644 frontend/src/features/admin/shows/ShowMetadataCard.tsx diff --git a/.env.example b/.env.example index bc0f181..ae70d45 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,13 @@ Media__LoudnessTargetLufs=-16 # Меняй при правке ЛОГИКИ рендера, чтобы пересобрать уже отрендеренные заставки. # Bumpers__TemplateVersion=1 +# ── Метаданные шоу/серий (TMDb / OMDb) ───────────────────────────────────── +# Ключи бесплатные: TMDb — themoviedb.org (Settings → API, v3 key), OMDb — omdbapi.com. +# Без ключа источник просто не показывается в админке (ручной режим работает всегда). +Metadata__Language=ru-RU +Metadata__Tmdb__ApiKey= +Metadata__Omdb__ApiKey= + # ── ASP.NET Core ────────────────────────────────────────────────────────── ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_HTTP_PORTS=8080 diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index db2b297..118ee2e 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -26,6 +26,7 @@ + diff --git a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs new file mode 100644 index 0000000..a253b97 --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs @@ -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 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); + + // Постеры отдаём публично (просто картинки, id не угадать) — чтобы работал . + app.MapGet("/api/metadata/shows/{showId:guid}/poster", ServePoster) + .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 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); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index feb83ca..d9a00b5 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -118,6 +118,7 @@ app.MapChannelEndpoints(); app.MapStreamingEndpoints(); app.MapMaintenanceEndpoints(); app.MapSettingsEndpoints(); +app.MapMetadataEndpoints(); // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. app.UseDefaultFiles(); diff --git a/backend/src/TeleWave.Api/appsettings.json b/backend/src/TeleWave.Api/appsettings.json index 501970f..b7de0ce 100644 --- a/backend/src/TeleWave.Api/appsettings.json +++ b/backend/src/TeleWave.Api/appsettings.json @@ -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": { diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs new file mode 100644 index 0000000..b596e2f --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataImageStore.cs @@ -0,0 +1,28 @@ +namespace TeleWave.Application.Common.Interfaces; + +/// +/// Локальное хранилище картинок метаданных (постеры/кадры) под metadata/ в корне хранилища. Скачивает +/// изображения к себе, чтобы не зависеть от внешнего CDN на этапе показа. +/// +public interface IMetadataImageStore +{ + /// Скачивает постер по URL и сохраняет для шоу. Возвращает относительный путь или null при ошибке. + Task DownloadShowPosterAsync( + Guid showId, + string url, + CancellationToken cancellationToken + ); + + /// Сохраняет загруженный вручную постер шоу. Возвращает относительный путь. + Task SaveShowPosterAsync( + Guid showId, + string extension, + Stream content, + CancellationToken cancellationToken + ); + + void DeleteShowImages(Guid showId); + + /// Абсолютный путь к файлу по относительному (с защитой от traversal) или null, если вне корня. + string? ResolveAbsolutePath(string relativePath); +} diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs new file mode 100644 index 0000000..fe880b4 --- /dev/null +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs @@ -0,0 +1,33 @@ +using TeleWave.Application.Metadata; + +namespace TeleWave.Application.Common.Interfaces; + +/// Источник метаданных о шоу/сериях (TMDb, OMDb). Реализация ходит во внешний API. +public interface IMetadataProvider +{ + /// Ключ источника: «tmdb», «omdb». + string Key { get; } + + Task> SearchShowsAsync( + string query, + CancellationToken cancellationToken + ); + + Task GetShowAsync(string externalId, CancellationToken cancellationToken); + + Task GetEpisodeAsync( + string externalId, + int season, + int episode, + CancellationToken cancellationToken + ); +} + +/// Резолвит провайдер по ключу и перечисляет реально настроенные (с API-ключом) источники. +public interface IMetadataProviderResolver +{ + IMetadataProvider? Resolve(string key); + + /// Ключи источников, у которых задан API-ключ (доступны в UI). + IReadOnlyList AvailableKeys { get; } +} diff --git a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs index bd3fcbe..663dcfc 100644 --- a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs @@ -45,7 +45,17 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext) .ToList(); return Result.Success( - new ShowDto(show.Id, show.Name, show.Kind, show.Description, episodeDtos) + new ShowDto( + show.Id, + show.Name, + show.Kind, + show.Description, + show.MetadataProvider, + show.MetadataExternalId, + show.Year, + show.PosterPath is not null, + episodeDtos + ) ); } } diff --git a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs index 4c4811d..8802f7b 100644 --- a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs @@ -32,7 +32,16 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext) .Where(season => season is not null) .Distinct() .Count(); - return new ShowSummaryDto(s.Id, s.Name, s.Kind, s.Episodes.Count, seasons, s.CreatedAt); + return new ShowSummaryDto( + s.Id, + s.Name, + s.Kind, + s.Episodes.Count, + seasons, + s.Year, + s.PosterPath is not null, + s.CreatedAt + ); }) .ToList(); } diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs index 16b8fce..71b8ba3 100644 --- a/backend/src/TeleWave.Application/Library/ShowDtos.cs +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -9,6 +9,8 @@ public sealed record ShowSummaryDto( ShowKind Kind, int EpisodeCount, int SeasonCount, + int? Year, + bool HasPoster, DateTimeOffset CreatedAt ); @@ -26,5 +28,9 @@ public sealed record ShowDto( string Name, ShowKind Kind, string? Description, + string? MetadataProvider, + string? MetadataExternalId, + int? Year, + bool HasPoster, IReadOnlyList Episodes ); diff --git a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommand.cs b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommand.cs new file mode 100644 index 0000000..87c1f06 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommand.cs @@ -0,0 +1,8 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata.ApplyShowMetadata; + +/// Применить к шоу выбранный результат из источника метаданных (с загрузкой постера). +public sealed record ApplyShowMetadataCommand(Guid ShowId, string Provider, string ExternalId) + : ICommand; diff --git a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs new file mode 100644 index 0000000..5749db2 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs @@ -0,0 +1,46 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library; + +namespace TeleWave.Application.Metadata.ApplyShowMetadata; + +public sealed class ApplyShowMetadataCommandHandler( + IAppDbContext dbContext, + IMetadataProviderResolver resolver, + IMetadataImageStore imageStore +) : ICommandHandler +{ + public async Task Handle( + ApplyShowMetadataCommand command, + CancellationToken cancellationToken + ) + { + var show = await dbContext.Shows.FirstOrDefaultAsync( + s => s.Id == command.ShowId, + cancellationToken + ); + if (show is null) + return Result.Failure(ShowErrors.NotFound); + + var provider = resolver.Resolve(command.Provider); + if (provider is null) + return Result.Failure(MetadataErrors.ProviderNotAvailable); + + var meta = await provider.GetShowAsync(command.ExternalId, cancellationToken); + if (meta is null) + return Result.Failure(MetadataErrors.NotFound); + + string? posterPath = null; + if (!string.IsNullOrEmpty(meta.PosterUrl)) + posterPath = await imageStore.DownloadShowPosterAsync( + show.Id, + meta.PosterUrl, + cancellationToken + ); + + show.ApplyMetadata(command.Provider, meta.ExternalId, meta.Overview, meta.Year, posterPath); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommand.cs b/backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommand.cs new file mode 100644 index 0000000..3e5b20d --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommand.cs @@ -0,0 +1,6 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata.ClearShowMetadata; + +public sealed record ClearShowMetadataCommand(Guid ShowId) : ICommand; diff --git a/backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommandHandler.cs new file mode 100644 index 0000000..cd6b7a2 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/ClearShowMetadata/ClearShowMetadataCommandHandler.cs @@ -0,0 +1,30 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library; + +namespace TeleWave.Application.Metadata.ClearShowMetadata; + +public sealed class ClearShowMetadataCommandHandler( + IAppDbContext dbContext, + IMetadataImageStore imageStore +) : ICommandHandler +{ + public async Task Handle( + ClearShowMetadataCommand command, + CancellationToken cancellationToken + ) + { + var show = await dbContext.Shows.FirstOrDefaultAsync( + s => s.Id == command.ShowId, + cancellationToken + ); + if (show is null) + return Result.Failure(ShowErrors.NotFound); + + imageStore.DeleteShowImages(show.Id); + show.ClearMetadata(); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Metadata/GetProviders/GetMetadataProvidersQuery.cs b/backend/src/TeleWave.Application/Metadata/GetProviders/GetMetadataProvidersQuery.cs new file mode 100644 index 0000000..bccf268 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/GetProviders/GetMetadataProvidersQuery.cs @@ -0,0 +1,6 @@ +using LiteCqrs; + +namespace TeleWave.Application.Metadata.GetProviders; + +/// Список доступных источников метаданных (у которых задан API-ключ). +public sealed record GetMetadataProvidersQuery : IQuery>; diff --git a/backend/src/TeleWave.Application/Metadata/GetProviders/GetMetadataProvidersQueryHandler.cs b/backend/src/TeleWave.Application/Metadata/GetProviders/GetMetadataProvidersQueryHandler.cs new file mode 100644 index 0000000..551945a --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/GetProviders/GetMetadataProvidersQueryHandler.cs @@ -0,0 +1,13 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Application.Metadata.GetProviders; + +public sealed class GetMetadataProvidersQueryHandler(IMetadataProviderResolver resolver) + : IQueryHandler> +{ + public Task> Handle( + GetMetadataProvidersQuery query, + CancellationToken cancellationToken + ) => Task.FromResult(resolver.AvailableKeys); +} diff --git a/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs b/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs new file mode 100644 index 0000000..204a243 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs @@ -0,0 +1,21 @@ +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata; + +public static class MetadataErrors +{ + public static readonly Error ProviderNotAvailable = Error.Validation( + "Metadata.ProviderNotAvailable", + "Источник метаданных недоступен — не задан API-ключ." + ); + + public static readonly Error NotFound = Error.NotFound( + "Metadata.NotFound", + "Метаданные по этому источнику не найдены." + ); + + public static readonly Error InvalidPoster = Error.Validation( + "Metadata.InvalidPoster", + "Недопустимый файл постера (формат или размер)." + ); +} diff --git a/backend/src/TeleWave.Application/Metadata/MetadataModels.cs b/backend/src/TeleWave.Application/Metadata/MetadataModels.cs new file mode 100644 index 0000000..9c5a93e --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/MetadataModels.cs @@ -0,0 +1,27 @@ +namespace TeleWave.Application.Metadata; + +/// Кандидат из поиска по названию в источнике метаданных. +public sealed record MetadataCandidate( + string ExternalId, + string Title, + int? Year, + string? Overview, + string? PosterUrl +); + +/// Метаданные шоу из источника (PosterUrl — полный URL, скачивается локально при применении). +public sealed record ShowMetadata( + string ExternalId, + string Title, + int? Year, + string? Overview, + string? PosterUrl +); + +/// Метаданные серии (для этапа 2). +public sealed record EpisodeMetadata( + string Title, + string? Overview, + string? StillUrl, + DateOnly? AirDate +); diff --git a/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQuery.cs b/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQuery.cs new file mode 100644 index 0000000..b69dba0 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQuery.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata.SearchShows; + +public sealed record SearchShowMetadataQuery(string Provider, string Query) + : IQuery>>; diff --git a/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs b/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs new file mode 100644 index 0000000..a8cec61 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs @@ -0,0 +1,25 @@ +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata.SearchShows; + +public sealed class SearchShowMetadataQueryHandler(IMetadataProviderResolver resolver) + : IQueryHandler>> +{ + public async Task>> Handle( + SearchShowMetadataQuery query, + CancellationToken cancellationToken + ) + { + var provider = resolver.Resolve(query.Provider); + if (provider is null) + return Result.Failure>(MetadataErrors.ProviderNotAvailable); + + if (string.IsNullOrWhiteSpace(query.Query)) + return Result.Success>([]); + + var results = await provider.SearchShowsAsync(query.Query, cancellationToken); + return Result.Success(results); + } +} diff --git a/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommand.cs b/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommand.cs new file mode 100644 index 0000000..bdccc0d --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommand.cs @@ -0,0 +1,7 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata.SetShowPoster; + +/// Привязать к шоу загруженный вручную постер (файл уже сохранён хранилищем). +public sealed record SetShowPosterCommand(Guid ShowId, string PosterPath) : ICommand; diff --git a/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommandHandler.cs new file mode 100644 index 0000000..a4b71e7 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/SetShowPoster/SetShowPosterCommandHandler.cs @@ -0,0 +1,27 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library; + +namespace TeleWave.Application.Metadata.SetShowPoster; + +public sealed class SetShowPosterCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + SetShowPosterCommand command, + CancellationToken cancellationToken + ) + { + var show = await dbContext.Shows.FirstOrDefaultAsync( + s => s.Id == command.ShowId, + cancellationToken + ); + if (show is null) + return Result.Failure(ShowErrors.NotFound); + + show.SetPosterPath(command.PosterPath); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Metadata/UpdateShowMetadata/UpdateShowMetadataCommand.cs b/backend/src/TeleWave.Application/Metadata/UpdateShowMetadata/UpdateShowMetadataCommand.cs new file mode 100644 index 0000000..8d400f8 --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/UpdateShowMetadata/UpdateShowMetadataCommand.cs @@ -0,0 +1,8 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Metadata.UpdateShowMetadata; + +/// Ручная правка метаданных шоу (описание/год) без внешнего источника. +public sealed record UpdateShowMetadataCommand(Guid ShowId, string? Description, int? Year) + : ICommand; diff --git a/backend/src/TeleWave.Application/Metadata/UpdateShowMetadata/UpdateShowMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/UpdateShowMetadata/UpdateShowMetadataCommandHandler.cs new file mode 100644 index 0000000..275a61b --- /dev/null +++ b/backend/src/TeleWave.Application/Metadata/UpdateShowMetadata/UpdateShowMetadataCommandHandler.cs @@ -0,0 +1,27 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Library; + +namespace TeleWave.Application.Metadata.UpdateShowMetadata; + +public sealed class UpdateShowMetadataCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + UpdateShowMetadataCommand command, + CancellationToken cancellationToken + ) + { + var show = await dbContext.Shows.FirstOrDefaultAsync( + s => s.Id == command.ShowId, + cancellationToken + ); + if (show is null) + return Result.Failure(ShowErrors.NotFound); + + show.UpdateMetadataManual(command.Description, command.Year); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs index 5436d5f..24c4d54 100644 --- a/backend/src/TeleWave.Domain/Library/Show.cs +++ b/backend/src/TeleWave.Domain/Library/Show.cs @@ -15,6 +15,18 @@ public class Show public ShowKind Kind { get; private set; } public DateTimeOffset CreatedAt { get; private set; } + // ── Метаданные (TMDb/OMDb/вручную) ── + /// Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы. + public string? MetadataProvider { get; private set; } + + /// Идентификатор шоу во внешнем источнике (для довыгрузки серий). + public string? MetadataExternalId { get; private set; } + + public int? Year { get; private set; } + + /// Относительный путь локального постера от корня хранилища или null. + public string? PosterPath { get; private set; } + /// Серии шоу (backing-field для EF). Порядок показа — по ; /// потребители сортируют явно (см. загрузчик планировщика). public IReadOnlyList Episodes => _episodes; @@ -56,4 +68,44 @@ public class Show } public bool CanAddEpisode => Kind != ShowKind.Single || _episodes.Count == 0; + + /// Применить метаданные из внешнего источника. Постер (уже скачанный локально) может быть null. + public void ApplyMetadata( + string provider, + string externalId, + string? description, + int? year, + string? posterPath + ) + { + MetadataProvider = provider; + MetadataExternalId = externalId; + if (!string.IsNullOrWhiteSpace(description)) + Description = description; + Year = year; + if (posterPath is not null) + PosterPath = posterPath; + } + + /// Ручная правка метаданных (без внешнего источника). + public void UpdateMetadataManual(string? description, int? year) + { + MetadataProvider = "manual"; + MetadataExternalId = null; + Description = description; + Year = year; + } + + /// Задать/снять локальный постер (после скачивания/загрузки/удаления файла). + public void SetPosterPath(string? path) => PosterPath = path; + + /// Сбросить все метаданные и постер (файл удаляет вызывающий по старому PosterPath). + public void ClearMetadata() + { + MetadataProvider = null; + MetadataExternalId = null; + Year = null; + PosterPath = null; + Description = null; + } } diff --git a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs index 260ab7a..ca441ab 100644 --- a/backend/src/TeleWave.Infrastructure/DependencyInjection.cs +++ b/backend/src/TeleWave.Infrastructure/DependencyInjection.cs @@ -13,6 +13,7 @@ using TeleWave.Domain.Broadcast.Scheduling; using TeleWave.Infrastructure.Broadcast; using TeleWave.Infrastructure.Identity; using TeleWave.Infrastructure.Media; +using TeleWave.Infrastructure.Metadata; using TeleWave.Infrastructure.Persistence; using TeleWave.Infrastructure.Settings; using TeleWave.Infrastructure.Streaming; @@ -96,10 +97,22 @@ public static class DependencyInjection AddMedia(services, configuration); AddBroadcast(services, configuration); + AddMetadata(services, configuration); return services; } + /// Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок. + private static void AddMetadata(IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(MetadataOptions.SectionName)); + services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15)); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + /// Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта. private static void AddBroadcast(IServiceCollection services, IConfiguration configuration) { diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs index a437f65..51d8428 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaPathResolver.cs @@ -18,6 +18,7 @@ public sealed class MediaPathResolver OriginalsDir = Path.Combine(_root, "originals"); AssetsDir = Path.Combine(_root, "assets"); BumpersDir = Path.Combine(_root, "bumpers"); + MetadataDir = Path.Combine(_root, "metadata"); } public string InboxDir { get; } @@ -28,6 +29,9 @@ public sealed class MediaPathResolver /// Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS. public string BumpersDir { get; } + /// Картинки метаданных (постеры/кадры), скачанные локально. + public string MetadataDir { get; } + public void EnsureDirectories() { Directory.CreateDirectory(InboxDir); @@ -35,6 +39,33 @@ public sealed class MediaPathResolver Directory.CreateDirectory(OriginalsDir); Directory.CreateDirectory(AssetsDir); Directory.CreateDirectory(BumpersDir); + Directory.CreateDirectory(MetadataDir); + } + + public string MetadataShowDir(Guid showId) => + EnsureWithinRoot(Path.Combine(MetadataDir, "shows", showId.ToString("N"))); + + /// Абсолютный путь к файлу постера шоу (extension — с точкой). + public string MetadataShowPosterPath(Guid showId, string extension) => + EnsureWithinRoot( + Path.Combine(MetadataDir, "shows", showId.ToString("N"), "poster" + extension) + ); + + /// Относительный путь постера от корня (для хранения в БД и отдачи). + public string MetadataShowPosterRelative(Guid showId, string extension) => + $"metadata/shows/{showId:N}/poster{extension}"; + + /// Абсолютный путь по относительному (с защитой от traversal) или null, если вне корня. + public string? ResolveRelative(string relativePath) + { + try + { + return EnsureWithinRoot(Path.Combine(_root, relativePath)); + } + catch (UnauthorizedAccessException) + { + return null; + } } public string BumperChannelDir(Guid channelId) => diff --git a/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs b/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs new file mode 100644 index 0000000..62afae2 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Metadata/MetadataImageStore.cs @@ -0,0 +1,88 @@ +using TeleWave.Application.Common.Interfaces; +using TeleWave.Infrastructure.Media; + +namespace TeleWave.Infrastructure.Metadata; + +/// Скачивает и хранит картинки метаданных локально под metadata/shows/{id}. +public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFactory httpFactory) + : IMetadataImageStore +{ + public async Task 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 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 string? ResolveAbsolutePath(string relativePath) + { + var abs = paths.ResolveRelative(relativePath); + return abs is not null && File.Exists(abs) ? abs : null; + } + + private async Task 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 + ".*")) + File.Delete(file); + } + + private static string ExtensionFor(string url, string? mediaType) => + mediaType switch + { + "image/png" => ".png", + "image/webp" => ".webp", + "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(); +} diff --git a/backend/src/TeleWave.Infrastructure/Metadata/MetadataOptions.cs b/backend/src/TeleWave.Infrastructure/Metadata/MetadataOptions.cs new file mode 100644 index 0000000..a89d373 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Metadata/MetadataOptions.cs @@ -0,0 +1,27 @@ +namespace TeleWave.Infrastructure.Metadata; + +public sealed class MetadataOptions +{ + public const string SectionName = "Metadata"; + + /// Язык метаданных (ISO, например «ru-RU»). + public string Language { get; init; } = "ru-RU"; + + public TmdbOptions Tmdb { get; init; } = new(); + public OmdbOptions Omdb { get; init; } = new(); +} + +public sealed class TmdbOptions +{ + public string ApiKey { get; init; } = string.Empty; + public string BaseUrl { get; init; } = "https://api.themoviedb.org/3"; + public string ImageBaseUrl { get; init; } = "https://image.tmdb.org/t/p"; + public string PosterSize { get; init; } = "w500"; + public string StillSize { get; init; } = "w300"; +} + +public sealed class OmdbOptions +{ + public string ApiKey { get; init; } = string.Empty; + public string BaseUrl { get; init; } = "https://www.omdbapi.com"; +} diff --git a/backend/src/TeleWave.Infrastructure/Metadata/MetadataProviderResolver.cs b/backend/src/TeleWave.Infrastructure/Metadata/MetadataProviderResolver.cs new file mode 100644 index 0000000..51e540c --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Metadata/MetadataProviderResolver.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; + +namespace TeleWave.Infrastructure.Metadata; + +/// Резолвит провайдеры по ключу; доступными считает только те, у кого задан API-ключ. +public sealed class MetadataProviderResolver : IMetadataProviderResolver +{ + private readonly Dictionary _byKey; + private readonly List _available = new(); + + public MetadataProviderResolver( + IEnumerable providers, + IOptions options + ) + { + _byKey = providers.ToDictionary(p => p.Key, StringComparer.OrdinalIgnoreCase); + var opt = options.Value; + if (_byKey.ContainsKey("tmdb") && !string.IsNullOrWhiteSpace(opt.Tmdb.ApiKey)) + _available.Add("tmdb"); + if (_byKey.ContainsKey("omdb") && !string.IsNullOrWhiteSpace(opt.Omdb.ApiKey)) + _available.Add("omdb"); + } + + public IMetadataProvider? Resolve(string key) => + _available.Contains(key, StringComparer.OrdinalIgnoreCase) + && _byKey.TryGetValue(key, out var provider) + ? provider + : null; + + public IReadOnlyList AvailableKeys => _available; +} diff --git a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs new file mode 100644 index 0000000..bcbfcea --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs @@ -0,0 +1,122 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Metadata; + +namespace TeleWave.Infrastructure.Metadata; + +/// Провайдер метаданных OMDb (omdbapi.com, данные IMDb). Требует API-ключ. +public sealed class OmdbMetadataProvider(IHttpClientFactory httpFactory, IOptions options) + : IMetadataProvider +{ + private readonly OmdbOptions _omdb = options.Value.Omdb; + + public string Key => "omdb"; + + public async Task> SearchShowsAsync( + string query, + CancellationToken cancellationToken + ) + { + var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&type=series&s={Uri.EscapeDataString(query)}"; + using var doc = await GetJsonAsync(url, cancellationToken); + if (doc is null || !doc.RootElement.TryGetProperty("Search", out var search)) + return []; + + var list = new List(); + foreach (var item in search.EnumerateArray()) + { + var id = Clean(GetString(item, "imdbID")); + if (id is null) + continue; + list.Add( + new MetadataCandidate( + id, + Clean(GetString(item, "Title")) ?? "—", + YearFrom(GetString(item, "Year")), + null, + Clean(GetString(item, "Poster")) + ) + ); + } + return list; + } + + public async Task GetShowAsync(string externalId, CancellationToken cancellationToken) + { + var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}"; + using var doc = await GetJsonAsync(url, cancellationToken); + if (doc is null || !IsResponseTrue(doc.RootElement)) + return null; + var root = doc.RootElement; + return new ShowMetadata( + externalId, + Clean(GetString(root, "Title")) ?? "—", + YearFrom(GetString(root, "Year")), + Clean(GetString(root, "Plot")), + Clean(GetString(root, "Poster")) + ); + } + + public async Task GetEpisodeAsync( + string externalId, + int season, + int episode, + CancellationToken cancellationToken + ) + { + var url = + $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}" + + $"&Season={season}&Episode={episode}"; + using var doc = await GetJsonAsync(url, cancellationToken); + if (doc is null || !IsResponseTrue(doc.RootElement)) + return null; + var root = doc.RootElement; + return new EpisodeMetadata( + Clean(GetString(root, "Title")) ?? "—", + Clean(GetString(root, "Plot")), + Clean(GetString(root, "Poster")), + DateFrom(GetString(root, "Released")) + ); + } + + private async Task GetJsonAsync(string url, CancellationToken cancellationToken) + { + var client = httpFactory.CreateClient("metadata"); + try + { + using var response = await client.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + return null; + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + } + catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException) + { + return null; + } + } + + private static bool IsResponseTrue(JsonElement root) => + GetString(root, "Response") is { } r && r.Equals("True", StringComparison.OrdinalIgnoreCase); + + private static string? GetString(JsonElement el, string name) => + el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + /// OMDb отдаёт «N/A» вместо отсутствующих значений — приводим к null. + private static string? Clean(string? value) => + string.IsNullOrWhiteSpace(value) || value == "N/A" ? null : value; + + private static int? YearFrom(string? year) + { + if (year is { Length: >= 4 } && int.TryParse(year.AsSpan(0, 4), out var y)) + return y; + return null; + } + + private static DateOnly? DateFrom(string? released) => + DateTime.TryParse(released, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) + ? DateOnly.FromDateTime(dt) + : null; +} diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs new file mode 100644 index 0000000..20dd35d --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs @@ -0,0 +1,122 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Options; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Metadata; + +namespace TeleWave.Infrastructure.Metadata; + +/// Провайдер метаданных TMDb (themoviedb.org). Требует API-ключ (v3). +public sealed class TmdbMetadataProvider(IHttpClientFactory httpFactory, IOptions options) + : IMetadataProvider +{ + private readonly MetadataOptions _options = options.Value; + + public string Key => "tmdb"; + + private TmdbOptions Tmdb => _options.Tmdb; + + public async Task> SearchShowsAsync( + string query, + CancellationToken cancellationToken + ) + { + var url = + $"{Tmdb.BaseUrl}/search/tv?api_key={Tmdb.ApiKey}&language={_options.Language}" + + $"&include_adult=false&query={Uri.EscapeDataString(query)}"; + using var doc = await GetJsonAsync(url, cancellationToken); + if (doc is null || !doc.RootElement.TryGetProperty("results", out var results)) + return []; + + var list = new List(); + foreach (var item in results.EnumerateArray()) + { + var id = GetInt(item, "id"); + if (id is null) + continue; + list.Add( + new MetadataCandidate( + id.Value.ToString(CultureInfo.InvariantCulture), + GetString(item, "name") ?? "—", + YearFrom(GetString(item, "first_air_date")), + GetString(item, "overview"), + PosterUrl(GetString(item, "poster_path")) + ) + ); + } + return list; + } + + public async Task GetShowAsync(string externalId, CancellationToken cancellationToken) + { + var url = $"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}"; + using var doc = await GetJsonAsync(url, cancellationToken); + if (doc is null) + return null; + var root = doc.RootElement; + return new ShowMetadata( + externalId, + GetString(root, "name") ?? "—", + YearFrom(GetString(root, "first_air_date")), + GetString(root, "overview"), + PosterUrl(GetString(root, "poster_path")) + ); + } + + public async Task GetEpisodeAsync( + string externalId, + int season, + int episode, + CancellationToken cancellationToken + ) + { + var url = + $"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}" + + $"?api_key={Tmdb.ApiKey}&language={_options.Language}"; + using var doc = await GetJsonAsync(url, cancellationToken); + if (doc is null) + return null; + var root = doc.RootElement; + return new EpisodeMetadata( + GetString(root, "name") ?? "—", + GetString(root, "overview"), + StillUrl(GetString(root, "still_path")), + DateFrom(GetString(root, "air_date")) + ); + } + + private string? PosterUrl(string? path) => + string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.PosterSize}{path}"; + + private string? StillUrl(string? path) => + string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.StillSize}{path}"; + + private async Task GetJsonAsync(string url, CancellationToken cancellationToken) + { + var client = httpFactory.CreateClient("metadata"); + try + { + using var response = await client.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + return null; + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + } + catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException) + { + return null; + } + } + + private static string? GetString(JsonElement el, string name) => + el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + private static int? GetInt(JsonElement el, string name) => + el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt32() : null; + + private static int? YearFrom(string? date) => + date is { Length: >= 4 } && int.TryParse(date.AsSpan(0, 4), out var y) ? y : null; + + private static DateOnly? DateFrom(string? date) => + DateOnly.TryParse(date, CultureInfo.InvariantCulture, out var d) ? d : null; +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725055823_ShowMetadata.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725055823_ShowMetadata.Designer.cs new file mode 100644 index 0000000..3d957ff --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725055823_ShowMetadata.Designer.cs @@ -0,0 +1,827 @@ +// +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("20260725055823_ShowMetadata")] + partial class ShowMetadata + { + /// + 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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperAccentColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperBackgroundColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperBackgroundColor2") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperBackgroundExtension") + .HasColumnType("text"); + + b.Property("BumperDurationSeconds") + .HasColumnType("integer"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperMode") + .HasColumnType("integer"); + + b.Property("BumperMusicExtension") + .HasColumnType("text"); + + b.Property("BumperNextLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperNowLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumperOnlyBetweenDifferentShows") + .HasColumnType("boolean"); + + b.Property("BumperRevision") + .HasColumnType("integer"); + + b.Property("BumperTextColor") + .IsRequired() + .HasColumnType("text"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextJingleIndex") + .HasColumnType("integer"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelJingle", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelJingle"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("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("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("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.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .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.ChannelJingle", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Jingles") + .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("Jingles"); + + 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 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725055823_ShowMetadata.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725055823_ShowMetadata.cs new file mode 100644 index 0000000..177dfa9 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725055823_ShowMetadata.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ShowMetadata : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MetadataExternalId", + table: "Shows", + type: "character varying(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "MetadataProvider", + table: "Shows", + type: "character varying(16)", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "PosterPath", + table: "Shows", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "Year", + table: "Shows", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MetadataExternalId", + table: "Shows"); + + migrationBuilder.DropColumn( + name: "MetadataProvider", + table: "Shows"); + + migrationBuilder.DropColumn( + name: "PosterPath", + table: "Shows"); + + migrationBuilder.DropColumn( + name: "Year", + table: "Shows"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 0027e62..35334e8 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -458,11 +458,26 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Kind") .HasColumnType("integer"); + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + b.Property("Name") .IsRequired() .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.Property("PosterPath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Year") + .HasColumnType("integer"); + b.HasKey("Id"); b.ToTable("Shows"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs index 87ff378..551da4b 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ShowConfiguration.cs @@ -11,6 +11,9 @@ public class ShowConfiguration : IEntityTypeConfiguration { builder.Property(x => x.Name).IsRequired().HasMaxLength(256); 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 .HasMany(x => x.Episodes) diff --git a/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj b/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj index d507d61..118e586 100644 --- a/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj +++ b/backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj @@ -11,6 +11,7 @@ + diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index 58af034..f1b8380 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -17,6 +17,7 @@ import { parseEpisodeName, } from '@/features/admin/media/episode-parse' import { formatDuration } from '@/features/admin/media/MediaPanel' +import { ShowMetadataCard } from './ShowMetadataCard' import { addEpisode, getShow, removeEpisode } from './api' type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode } @@ -140,6 +141,8 @@ export function ShowDetail({ showId }: { showId: string }) { )} + + {canAdd && (
diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx new file mode 100644 index 0000000..ff34074 --- /dev/null +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -0,0 +1,237 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { HttpError } from '@/shared/api/client' +import type { MetadataCandidate, ShowDto } from '@/shared/api/types' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' +import { + applyMetadata, + clearMetadata, + getMetadataProviders, + searchMetadata, + showPosterUrl, + updateMetadata, + uploadPoster, +} from './api' + +export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) { + const { t } = useTranslation() + const posterInput = useRef(null) + const [bust, setBust] = useState(0) + const [provider, setProvider] = useState('') + const [query, setQuery] = useState(show.name) + const [results, setResults] = useState([]) + const [description, setDescription] = useState(show.description ?? '') + const [year, setYear] = useState(show.year != null ? String(show.year) : '') + + const { data: providers } = useQuery({ + queryKey: ['admin', 'metadata', 'providers'], + queryFn: getMetadataProviders, + }) + + const onError = (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + const changed = () => { + setBust(Date.now()) + onChanged() + } + + const effectiveProvider = provider || providers?.[0] || '' + + const search = useMutation({ + mutationFn: () => searchMetadata(effectiveProvider, query.trim()), + onSuccess: setResults, + onError, + }) + const apply = useMutation({ + mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId), + onSuccess: () => { + setResults([]) + toast.success(t('admin.metadata.applied')) + changed() + }, + onError, + }) + const saveManual = useMutation({ + mutationFn: () => + updateMetadata(show.id, { + description: description.trim() || null, + year: year.trim() ? Number(year) : null, + }), + onSuccess: () => { + toast.success(t('settings.saved')) + changed() + }, + onError, + }) + const clear = useMutation({ + mutationFn: () => clearMetadata(show.id), + onSuccess: () => { + setDescription('') + setYear('') + changed() + }, + onError, + }) + const posterUpload = useMutation({ + mutationFn: (file: File) => uploadPoster(show.id, file), + onSuccess: changed, + onError, + }) + + return ( + + + {t('admin.metadata.title')} + + + {/* Постер */} +
+
+ {show.hasPoster ? ( + + ) : ( + {t('admin.metadata.noPoster')} + )} +
+ { + const file = e.target.files?.[0] + if (file) posterUpload.mutate(file) + e.target.value = '' + }} + /> + +
+ + {/* Поиск + ручная правка */} +
+ {providers && providers.length > 0 && ( +
+
+
+ + +
+ setQuery(e.target.value)} + placeholder={t('admin.metadata.searchPlaceholder')} + /> + +
+ + {results.length > 0 && ( +
    + {results.map((r) => ( +
  • + {r.posterUrl ? ( + + ) : ( +
    + )} +
    +
    + {r.title} + {r.year != null && ( + ({r.year}) + )} +
    + {r.overview && ( +

    {r.overview}

    + )} +
    + +
  • + ))} +
+ )} +
+ )} + +
+
+
+ +