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.
This commit is contained in:
@@ -16,8 +16,10 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
|||||||
var channel = await dbContext.Channels.AsNoTracking()
|
var channel = await dbContext.Channels.AsNoTracking()
|
||||||
.Include(c => c.Shows)
|
.Include(c => c.Shows)
|
||||||
.Include(c => c.Ads)
|
.Include(c => c.Ads)
|
||||||
|
.Include(c => c.Jingles)
|
||||||
.Include(c => c.Overrides)
|
.Include(c => c.Overrides)
|
||||||
.ThenInclude(o => o.Shows)
|
.ThenInclude(o => o.Shows)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public sealed class ScheduleGenerator(
|
|||||||
.Include(c => c.Jingles)
|
.Include(c => c.Jingles)
|
||||||
.Include(c => c.Overrides)
|
.Include(c => c.Overrides)
|
||||||
.ThenInclude(o => o.Shows)
|
.ThenInclude(o => o.Shows)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
|
||||||
|
|
||||||
if (channel is null || !channel.IsEnabled)
|
if (channel is null || !channel.IsEnabled)
|
||||||
|
|||||||
@@ -23,4 +23,9 @@ public static class MetadataErrors
|
|||||||
"Metadata.NoLinkedSource",
|
"Metadata.NoLinkedSource",
|
||||||
"У шоу не привязан внешний источник — сначала найдите шоу в TMDb/OMDb."
|
"У шоу не привязан внешний источник — сначала найдите шоу в TMDb/OMDb."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static readonly Error ProviderRequestFailed = Error.Validation(
|
||||||
|
"Metadata.ProviderRequestFailed",
|
||||||
|
"Источник метаданных вернул ошибку — проверьте API-ключ."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -19,7 +19,17 @@ public sealed class SearchShowMetadataQueryHandler(IMetadataProviderResolver res
|
|||||||
if (string.IsNullOrWhiteSpace(query.Query))
|
if (string.IsNullOrWhiteSpace(query.Query))
|
||||||
return Result.Success<IReadOnlyList<MetadataCandidate>>([]);
|
return Result.Success<IReadOnlyList<MetadataCandidate>>([]);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
var results = await provider.SearchShowsAsync(query.Query, cancellationToken);
|
var results = await provider.SearchShowsAsync(query.Query, cancellationToken);
|
||||||
return Result.Success(results);
|
return Result.Success(results);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Не-2xx (например, 401 — неверный ключ) или сетевой сбой источника.
|
||||||
|
return Result.Failure<IReadOnlyList<MetadataCandidate>>(
|
||||||
|
MetadataErrors.ProviderRequestFailed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public sealed class OmdbMetadataProvider(IHttpClientFactory httpFactory, IOption
|
|||||||
{
|
{
|
||||||
var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&type=series&s={Uri.EscapeDataString(query)}";
|
var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&type=series&s={Uri.EscapeDataString(query)}";
|
||||||
using var doc = await GetJsonAsync(url, cancellationToken);
|
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 [];
|
return [];
|
||||||
|
|
||||||
var list = new List<MetadataCandidate>();
|
var list = new List<MetadataCandidate>();
|
||||||
@@ -46,7 +46,7 @@ public sealed class OmdbMetadataProvider(IHttpClientFactory httpFactory, IOption
|
|||||||
public async Task<ShowMetadata?> GetShowAsync(string externalId, CancellationToken cancellationToken)
|
public async Task<ShowMetadata?> GetShowAsync(string externalId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}";
|
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))
|
if (doc is null || !IsResponseTrue(doc.RootElement))
|
||||||
return null;
|
return null;
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
@@ -69,7 +69,7 @@ public sealed class OmdbMetadataProvider(IHttpClientFactory httpFactory, IOption
|
|||||||
var url =
|
var url =
|
||||||
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}"
|
$"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&i={Uri.EscapeDataString(externalId)}"
|
||||||
+ $"&Season={season}&Episode={episode}";
|
+ $"&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))
|
if (doc is null || !IsResponseTrue(doc.RootElement))
|
||||||
return null;
|
return null;
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
@@ -81,17 +81,23 @@ public sealed class OmdbMetadataProvider(IHttpClientFactory httpFactory, IOption
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonDocument?> GetJsonAsync(string url, CancellationToken cancellationToken)
|
/// <summary>GET+parse; бросает при не-2xx/сетевой ошибке (для поиска — чтобы показать сбой).</summary>
|
||||||
|
private async Task<JsonDocument> GetJsonAsync(string url, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var client = httpFactory.CreateClient("metadata");
|
var client = httpFactory.CreateClient("metadata");
|
||||||
try
|
|
||||||
{
|
|
||||||
using var response = await client.GetAsync(url, cancellationToken);
|
using var response = await client.GetAsync(url, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
response.EnsureSuccessStatusCode();
|
||||||
return null;
|
|
||||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Как GetJsonAsync, но глотает ошибки в null (для get/episode — деградируем мягко).</summary>
|
||||||
|
private async Task<JsonDocument?> TryGetJsonAsync(string url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await GetJsonAsync(url, cancellationToken);
|
||||||
|
}
|
||||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
|
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public sealed class TmdbMetadataProvider(IHttpClientFactory httpFactory, IOption
|
|||||||
$"{Tmdb.BaseUrl}/search/tv?api_key={Tmdb.ApiKey}&language={_options.Language}"
|
$"{Tmdb.BaseUrl}/search/tv?api_key={Tmdb.ApiKey}&language={_options.Language}"
|
||||||
+ $"&include_adult=false&query={Uri.EscapeDataString(query)}";
|
+ $"&include_adult=false&query={Uri.EscapeDataString(query)}";
|
||||||
using var doc = await GetJsonAsync(url, cancellationToken);
|
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 [];
|
return [];
|
||||||
|
|
||||||
var list = new List<MetadataCandidate>();
|
var list = new List<MetadataCandidate>();
|
||||||
@@ -50,7 +50,7 @@ public sealed class TmdbMetadataProvider(IHttpClientFactory httpFactory, IOption
|
|||||||
public async Task<ShowMetadata?> GetShowAsync(string externalId, CancellationToken cancellationToken)
|
public async Task<ShowMetadata?> GetShowAsync(string externalId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var url = $"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}";
|
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)
|
if (doc is null)
|
||||||
return null;
|
return null;
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
@@ -73,7 +73,7 @@ public sealed class TmdbMetadataProvider(IHttpClientFactory httpFactory, IOption
|
|||||||
var url =
|
var url =
|
||||||
$"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}"
|
$"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}"
|
||||||
+ $"?api_key={Tmdb.ApiKey}&language={_options.Language}";
|
+ $"?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)
|
if (doc is null)
|
||||||
return null;
|
return null;
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
@@ -91,17 +91,23 @@ public sealed class TmdbMetadataProvider(IHttpClientFactory httpFactory, IOption
|
|||||||
private string? StillUrl(string? path) =>
|
private string? StillUrl(string? path) =>
|
||||||
string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.StillSize}{path}";
|
string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.StillSize}{path}";
|
||||||
|
|
||||||
private async Task<JsonDocument?> GetJsonAsync(string url, CancellationToken cancellationToken)
|
/// <summary>GET+parse; бросает при не-2xx/сетевой ошибке (для поиска — чтобы показать сбой).</summary>
|
||||||
|
private async Task<JsonDocument> GetJsonAsync(string url, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var client = httpFactory.CreateClient("metadata");
|
var client = httpFactory.CreateClient("metadata");
|
||||||
try
|
|
||||||
{
|
|
||||||
using var response = await client.GetAsync(url, cancellationToken);
|
using var response = await client.GetAsync(url, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
response.EnsureSuccessStatusCode();
|
||||||
return null;
|
|
||||||
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Как GetJsonAsync, но глотает ошибки в null (для get/episode — деградируем мягко).</summary>
|
||||||
|
private async Task<JsonDocument?> TryGetJsonAsync(string url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await GetJsonAsync(url, cancellationToken);
|
||||||
|
}
|
||||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
|
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
const [provider, setProvider] = useState('')
|
const [provider, setProvider] = useState('')
|
||||||
const [query, setQuery] = useState(show.name)
|
const [query, setQuery] = useState(show.name)
|
||||||
const [results, setResults] = useState<MetadataCandidate[]>([])
|
const [results, setResults] = useState<MetadataCandidate[]>([])
|
||||||
|
const [searched, setSearched] = useState(false)
|
||||||
const [description, setDescription] = useState(show.description ?? '')
|
const [description, setDescription] = useState(show.description ?? '')
|
||||||
const [year, setYear] = useState(show.year != null ? String(show.year) : '')
|
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({
|
const search = useMutation({
|
||||||
mutationFn: () => searchMetadata(effectiveProvider, query.trim()),
|
mutationFn: () => searchMetadata(effectiveProvider, query.trim()),
|
||||||
onSuccess: setResults,
|
onSuccess: (data) => {
|
||||||
|
setResults(data)
|
||||||
|
setSearched(true)
|
||||||
|
},
|
||||||
onError,
|
onError,
|
||||||
})
|
})
|
||||||
const apply = useMutation({
|
const apply = useMutation({
|
||||||
mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId),
|
mutationFn: (externalId: string) => applyMetadata(show.id, effectiveProvider, externalId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setResults([])
|
setResults([])
|
||||||
|
setSearched(false)
|
||||||
toast.success(t('admin.metadata.applied'))
|
toast.success(t('admin.metadata.applied'))
|
||||||
changed()
|
changed()
|
||||||
},
|
},
|
||||||
@@ -159,7 +164,10 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
<Input
|
<Input
|
||||||
className="min-w-40 flex-1"
|
className="min-w-40 flex-1"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setSearched(false)
|
||||||
|
}}
|
||||||
placeholder={t('admin.metadata.searchPlaceholder')}
|
placeholder={t('admin.metadata.searchPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" disabled={search.isPending || !query.trim()} onClick={() => search.mutate()}>
|
<Button size="sm" disabled={search.isPending || !query.trim()} onClick={() => search.mutate()}>
|
||||||
@@ -167,6 +175,10 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{searched && results.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.metadata.nothingFound')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md">
|
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md">
|
||||||
{results.map((r) => (
|
{results.map((r) => (
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ const resources = {
|
|||||||
sourceLabel: 'Источник',
|
sourceLabel: 'Источник',
|
||||||
searchPlaceholder: 'Название для поиска',
|
searchPlaceholder: 'Название для поиска',
|
||||||
searchBtn: 'Искать',
|
searchBtn: 'Искать',
|
||||||
|
nothingFound: 'Ничего не найдено',
|
||||||
apply: 'Применить',
|
apply: 'Применить',
|
||||||
applied: 'Метаданные применены',
|
applied: 'Метаданные применены',
|
||||||
overview: 'Описание',
|
overview: 'Описание',
|
||||||
@@ -544,6 +545,7 @@ const resources = {
|
|||||||
sourceLabel: 'Source',
|
sourceLabel: 'Source',
|
||||||
searchPlaceholder: 'Title to search',
|
searchPlaceholder: 'Title to search',
|
||||||
searchBtn: 'Search',
|
searchBtn: 'Search',
|
||||||
|
nothingFound: 'Nothing found',
|
||||||
apply: 'Apply',
|
apply: 'Apply',
|
||||||
applied: 'Metadata applied',
|
applied: 'Metadata applied',
|
||||||
overview: 'Overview',
|
overview: 'Overview',
|
||||||
|
|||||||
Reference in New Issue
Block a user