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

This commit is contained in:
Leonid Pershin
2026-07-25 09:07:55 +03:00
parent ba023bc416
commit 0d2dee815e
42 changed files with 2271 additions and 4 deletions
@@ -0,0 +1,28 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Локальное хранилище картинок метаданных (постеры/кадры) под metadata/ в корне хранилища. Скачивает
/// изображения к себе, чтобы не зависеть от внешнего CDN на этапе показа.
/// </summary>
public interface IMetadataImageStore
{
/// <summary>Скачивает постер по URL и сохраняет для шоу. Возвращает относительный путь или null при ошибке.</summary>
Task<string?> DownloadShowPosterAsync(
Guid showId,
string url,
CancellationToken cancellationToken
);
/// <summary>Сохраняет загруженный вручную постер шоу. Возвращает относительный путь.</summary>
Task<string> SaveShowPosterAsync(
Guid showId,
string extension,
Stream content,
CancellationToken cancellationToken
);
void DeleteShowImages(Guid showId);
/// <summary>Абсолютный путь к файлу по относительному (с защитой от traversal) или null, если вне корня.</summary>
string? ResolveAbsolutePath(string relativePath);
}
@@ -0,0 +1,33 @@
using TeleWave.Application.Metadata;
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Источник метаданных о шоу/сериях (TMDb, OMDb). Реализация ходит во внешний API.</summary>
public interface IMetadataProvider
{
/// <summary>Ключ источника: «tmdb», «omdb».</summary>
string Key { get; }
Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
string query,
CancellationToken cancellationToken
);
Task<ShowMetadata?> GetShowAsync(string externalId, CancellationToken cancellationToken);
Task<EpisodeMetadata?> GetEpisodeAsync(
string externalId,
int season,
int episode,
CancellationToken cancellationToken
);
}
/// <summary>Резолвит провайдер по ключу и перечисляет реально настроенные (с API-ключом) источники.</summary>
public interface IMetadataProviderResolver
{
IMetadataProvider? Resolve(string key);
/// <summary>Ключи источников, у которых задан API-ключ (доступны в UI).</summary>
IReadOnlyList<string> AvailableKeys { get; }
}
@@ -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
)
);
}
}
@@ -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();
}
@@ -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<EpisodeDto> Episodes
);
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Metadata.ApplyShowMetadata;
/// <summary>Применить к шоу выбранный результат из источника метаданных (с загрузкой постера).</summary>
public sealed record ApplyShowMetadataCommand(Guid ShowId, string Provider, string ExternalId)
: ICommand<Result>;
@@ -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<ApplyShowMetadataCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Metadata.ClearShowMetadata;
public sealed record ClearShowMetadataCommand(Guid ShowId) : ICommand<Result>;
@@ -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<ClearShowMetadataCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
namespace TeleWave.Application.Metadata.GetProviders;
/// <summary>Список доступных источников метаданных (у которых задан API-ключ).</summary>
public sealed record GetMetadataProvidersQuery : IQuery<IReadOnlyList<string>>;
@@ -0,0 +1,13 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Metadata.GetProviders;
public sealed class GetMetadataProvidersQueryHandler(IMetadataProviderResolver resolver)
: IQueryHandler<GetMetadataProvidersQuery, IReadOnlyList<string>>
{
public Task<IReadOnlyList<string>> Handle(
GetMetadataProvidersQuery query,
CancellationToken cancellationToken
) => Task.FromResult(resolver.AvailableKeys);
}
@@ -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",
"Недопустимый файл постера (формат или размер)."
);
}
@@ -0,0 +1,27 @@
namespace TeleWave.Application.Metadata;
/// <summary>Кандидат из поиска по названию в источнике метаданных.</summary>
public sealed record MetadataCandidate(
string ExternalId,
string Title,
int? Year,
string? Overview,
string? PosterUrl
);
/// <summary>Метаданные шоу из источника (PosterUrl — полный URL, скачивается локально при применении).</summary>
public sealed record ShowMetadata(
string ExternalId,
string Title,
int? Year,
string? Overview,
string? PosterUrl
);
/// <summary>Метаданные серии (для этапа 2).</summary>
public sealed record EpisodeMetadata(
string Title,
string? Overview,
string? StillUrl,
DateOnly? AirDate
);
@@ -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<Result<IReadOnlyList<MetadataCandidate>>>;
@@ -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<SearchShowMetadataQuery, Result<IReadOnlyList<MetadataCandidate>>>
{
public async Task<Result<IReadOnlyList<MetadataCandidate>>> Handle(
SearchShowMetadataQuery query,
CancellationToken cancellationToken
)
{
var provider = resolver.Resolve(query.Provider);
if (provider is null)
return Result.Failure<IReadOnlyList<MetadataCandidate>>(MetadataErrors.ProviderNotAvailable);
if (string.IsNullOrWhiteSpace(query.Query))
return Result.Success<IReadOnlyList<MetadataCandidate>>([]);
var results = await provider.SearchShowsAsync(query.Query, cancellationToken);
return Result.Success(results);
}
}
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Metadata.SetShowPoster;
/// <summary>Привязать к шоу загруженный вручную постер (файл уже сохранён хранилищем).</summary>
public sealed record SetShowPosterCommand(Guid ShowId, string PosterPath) : ICommand<Result>;
@@ -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<SetShowPosterCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Metadata.UpdateShowMetadata;
/// <summary>Ручная правка метаданных шоу (описание/год) без внешнего источника.</summary>
public sealed record UpdateShowMetadataCommand(Guid ShowId, string? Description, int? Year)
: ICommand<Result>;
@@ -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<UpdateShowMetadataCommand, Result>
{
public async Task<Result> 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();
}
}