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}

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