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,88 @@
using TeleWave.Application.Common.Interfaces;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Infrastructure.Metadata;
/// <summary>Скачивает и хранит картинки метаданных локально под metadata/shows/{id}.</summary>
public sealed class MetadataImageStore(MediaPathResolver paths, IHttpClientFactory httpFactory)
: IMetadataImageStore
{
public async Task<string?> 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<string> 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<string> 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();
}
@@ -0,0 +1,27 @@
namespace TeleWave.Infrastructure.Metadata;
public sealed class MetadataOptions
{
public const string SectionName = "Metadata";
/// <summary>Язык метаданных (ISO, например «ru-RU»).</summary>
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";
}
@@ -0,0 +1,32 @@
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Infrastructure.Metadata;
/// <summary>Резолвит провайдеры по ключу; доступными считает только те, у кого задан API-ключ.</summary>
public sealed class MetadataProviderResolver : IMetadataProviderResolver
{
private readonly Dictionary<string, IMetadataProvider> _byKey;
private readonly List<string> _available = new();
public MetadataProviderResolver(
IEnumerable<IMetadataProvider> providers,
IOptions<MetadataOptions> 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<string> AvailableKeys => _available;
}
@@ -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;
/// <summary>Провайдер метаданных OMDb (omdbapi.com, данные IMDb). Требует API-ключ.</summary>
public sealed class OmdbMetadataProvider(IHttpClientFactory httpFactory, IOptions<MetadataOptions> options)
: IMetadataProvider
{
private readonly OmdbOptions _omdb = options.Value.Omdb;
public string Key => "omdb";
public async Task<IReadOnlyList<MetadataCandidate>> 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<MetadataCandidate>();
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<ShowMetadata?> 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<EpisodeMetadata?> 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<JsonDocument?> 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;
/// <summary>OMDb отдаёт «N/A» вместо отсутствующих значений — приводим к null.</summary>
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;
}
@@ -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;
/// <summary>Провайдер метаданных TMDb (themoviedb.org). Требует API-ключ (v3).</summary>
public sealed class TmdbMetadataProvider(IHttpClientFactory httpFactory, IOptions<MetadataOptions> options)
: IMetadataProvider
{
private readonly MetadataOptions _options = options.Value;
public string Key => "tmdb";
private TmdbOptions Tmdb => _options.Tmdb;
public async Task<IReadOnlyList<MetadataCandidate>> 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<MetadataCandidate>();
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<ShowMetadata?> 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<EpisodeMetadata?> 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<JsonDocument?> 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;
}