From 8ca585a5be63edc15ec6cc688a5db29a920bbf07 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 09:58:59 +0300 Subject: [PATCH] Enhance metadata handling and UI feedback: add support for jingles in channel queries, implement error handling for metadata provider requests, and improve search functionality in ShowMetadataCard with user feedback for no results found. Update translations for new UI messages. --- .../GetChannel/GetChannelQueryHandler.cs | 2 ++ .../Broadcast/Scheduling/ScheduleGenerator.cs | 1 + .../Metadata/MetadataErrors.cs | 5 ++++ .../SearchShowMetadataQueryHandler.cs | 14 +++++++++-- .../Metadata/OmdbMetadataProvider.cs | 24 ++++++++++++------- .../Metadata/TmdbMetadataProvider.cs | 24 ++++++++++++------- .../features/admin/shows/ShowMetadataCard.tsx | 16 +++++++++++-- frontend/src/shared/lib/i18n.ts | 2 ++ 8 files changed, 66 insertions(+), 22 deletions(-) diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index 744369d..0a563cb 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -16,8 +16,10 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) var channel = await dbContext.Channels.AsNoTracking() .Include(c => c.Shows) .Include(c => c.Ads) + .Include(c => c.Jingles) .Include(c => c.Overrides) .ThenInclude(o => o.Shows) + .AsSplitQuery() .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken); if (channel is null) return Result.Failure(ChannelErrors.NotFound); diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index 0526d4f..4324d31 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -52,6 +52,7 @@ public sealed class ScheduleGenerator( .Include(c => c.Jingles) .Include(c => c.Overrides) .ThenInclude(o => o.Shows) + .AsSplitQuery() .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken); if (channel is null || !channel.IsEnabled) diff --git a/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs b/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs index 31d9bcb..35d6e95 100644 --- a/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs +++ b/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs @@ -23,4 +23,9 @@ public static class MetadataErrors "Metadata.NoLinkedSource", "У шоу не привязан внешний источник — сначала найдите шоу в TMDb/OMDb." ); + + public static readonly Error ProviderRequestFailed = Error.Validation( + "Metadata.ProviderRequestFailed", + "Источник метаданных вернул ошибку — проверьте API-ключ." + ); } diff --git a/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs b/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs index a8cec61..29e760b 100644 --- a/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs @@ -19,7 +19,17 @@ public sealed class SearchShowMetadataQueryHandler(IMetadataProviderResolver res if (string.IsNullOrWhiteSpace(query.Query)) return Result.Success>([]); - var results = await provider.SearchShowsAsync(query.Query, cancellationToken); - return Result.Success(results); + try + { + var results = await provider.SearchShowsAsync(query.Query, cancellationToken); + return Result.Success(results); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + // Не-2xx (например, 401 — неверный ключ) или сетевой сбой источника. + return Result.Failure>( + MetadataErrors.ProviderRequestFailed + ); + } } } diff --git a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs index bcbfcea..96cf749 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs @@ -21,7 +21,7 @@ public sealed class OmdbMetadataProvider(IHttpClientFactory httpFactory, IOption { 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)) + if (!doc.RootElement.TryGetProperty("Search", out var search)) return []; var list = new List(); @@ -46,7 +46,7 @@ public sealed class OmdbMetadataProvider(IHttpClientFactory httpFactory, IOption 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); + using var doc = await TryGetJsonAsync(url, cancellationToken); if (doc is null || !IsResponseTrue(doc.RootElement)) return null; var root = doc.RootElement; @@ -69,7 +69,7 @@ public sealed class OmdbMetadataProvider(IHttpClientFactory httpFactory, IOption var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}" + $"&Season={season}&Episode={episode}"; - using var doc = await GetJsonAsync(url, cancellationToken); + using var doc = await TryGetJsonAsync(url, cancellationToken); if (doc is null || !IsResponseTrue(doc.RootElement)) return null; var root = doc.RootElement; @@ -81,16 +81,22 @@ public sealed class OmdbMetadataProvider(IHttpClientFactory httpFactory, IOption ); } - private async Task GetJsonAsync(string url, CancellationToken cancellationToken) + /// GET+parse; бросает при не-2xx/сетевой ошибке (для поиска — чтобы показать сбой). + private async Task GetJsonAsync(string url, CancellationToken cancellationToken) { var client = httpFactory.CreateClient("metadata"); + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + } + + /// Как GetJsonAsync, но глотает ошибки в null (для get/episode — деградируем мягко). + private async Task TryGetJsonAsync(string url, CancellationToken cancellationToken) + { 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); + return await GetJsonAsync(url, cancellationToken); } catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException) { diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs index 20dd35d..b0f78e2 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs @@ -25,7 +25,7 @@ public sealed class TmdbMetadataProvider(IHttpClientFactory httpFactory, IOption $"{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)) + if (!doc.RootElement.TryGetProperty("results", out var results)) return []; var list = new List(); @@ -50,7 +50,7 @@ public sealed class TmdbMetadataProvider(IHttpClientFactory httpFactory, IOption 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); + using var doc = await TryGetJsonAsync(url, cancellationToken); if (doc is null) return null; var root = doc.RootElement; @@ -73,7 +73,7 @@ public sealed class TmdbMetadataProvider(IHttpClientFactory httpFactory, IOption var url = $"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}" + $"?api_key={Tmdb.ApiKey}&language={_options.Language}"; - using var doc = await GetJsonAsync(url, cancellationToken); + using var doc = await TryGetJsonAsync(url, cancellationToken); if (doc is null) return null; var root = doc.RootElement; @@ -91,16 +91,22 @@ public sealed class TmdbMetadataProvider(IHttpClientFactory httpFactory, IOption private string? StillUrl(string? path) => string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.StillSize}{path}"; - private async Task GetJsonAsync(string url, CancellationToken cancellationToken) + /// GET+parse; бросает при не-2xx/сетевой ошибке (для поиска — чтобы показать сбой). + private async Task GetJsonAsync(string url, CancellationToken cancellationToken) { var client = httpFactory.CreateClient("metadata"); + using var response = await client.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + } + + /// Как GetJsonAsync, но глотает ошибки в null (для get/episode — деградируем мягко). + private async Task TryGetJsonAsync(string url, CancellationToken cancellationToken) + { 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); + return await GetJsonAsync(url, cancellationToken); } catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException) { diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index a9a8de5..9d3d99e 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -27,6 +27,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged const [provider, setProvider] = useState('') const [query, setQuery] = useState(show.name) const [results, setResults] = useState([]) + const [searched, setSearched] = useState(false) const [description, setDescription] = useState(show.description ?? '') const [year, setYear] = useState(show.year != null ? String(show.year) : '') @@ -46,13 +47,17 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged const search = useMutation({ mutationFn: () => searchMetadata(effectiveProvider, query.trim()), - onSuccess: setResults, + onSuccess: (data) => { + setResults(data) + setSearched(true) + }, onError, }) const apply = useMutation({ mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId), onSuccess: () => { setResults([]) + setSearched(false) toast.success(t('admin.metadata.applied')) changed() }, @@ -159,7 +164,10 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged setQuery(e.target.value)} + onChange={(e) => { + setQuery(e.target.value) + setSearched(false) + }} placeholder={t('admin.metadata.searchPlaceholder')} /> + {searched && results.length === 0 && ( +

{t('admin.metadata.nothingFound')}

+ )} + {results.length > 0 && (
    {results.map((r) => ( diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 4ba7ac1..336c2b0 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -266,6 +266,7 @@ const resources = { sourceLabel: 'Источник', searchPlaceholder: 'Название для поиска', searchBtn: 'Искать', + nothingFound: 'Ничего не найдено', apply: 'Применить', applied: 'Метаданные применены', overview: 'Описание', @@ -544,6 +545,7 @@ const resources = { sourceLabel: 'Source', searchPlaceholder: 'Title to search', searchBtn: 'Search', + nothingFound: 'Nothing found', apply: 'Apply', applied: 'Metadata applied', overview: 'Overview',