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',