Enhance logging in RefreshShowEpisodesMetadataCommandHandler and TmdbMetadataProvider for image retrieval failures
ci / build-backend (push) Successful in 5m2s
ci / build-frontend (push) Successful in 55s
ci / tests (push) Successful in 1m44s
ci / sonar (push) Successful in 4m31s

Updated the RefreshShowEpisodesMetadataCommandHandler to include logging for scenarios where episode still images are not retrieved successfully. Introduced warning logs for missing still URLs and download failures, improving traceability. Additionally, modified the TmdbMetadataProvider to implement a fallback mechanism for episode still images, ensuring that appropriate imagery is displayed even when localized images are unavailable. Updated related tests to verify the correct behavior of these logging enhancements and fallback logic.
This commit is contained in:
Leonid Pershin
2026-07-30 02:08:50 +03:00
parent 3fecd81333
commit 5032614773
5 changed files with 73 additions and 14 deletions
@@ -1,5 +1,6 @@
using LiteCqrs; using LiteCqrs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models; using TeleWave.Application.Common.Models;
using TeleWave.Application.Library; using TeleWave.Application.Library;
@@ -12,7 +13,8 @@ public sealed class RefreshShowEpisodesMetadataCommandHandler(
IAppDbContext dbContext, IAppDbContext dbContext,
IMetadataProviderResolver resolver, IMetadataProviderResolver resolver,
IImageDownloader downloader, IImageDownloader downloader,
IImageStore imageStore IImageStore imageStore,
ILogger<RefreshShowEpisodesMetadataCommandHandler> logger
) : ICommandHandler<RefreshShowEpisodesMetadataCommand, Result<int>> ) : ICommandHandler<RefreshShowEpisodesMetadataCommand, Result<int>>
{ {
public async Task<Result<int>> Handle( public async Task<Result<int>> Handle(
@@ -99,12 +101,27 @@ public sealed class RefreshShowEpisodesMetadataCommandHandler(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
// Как и с постером шоу: снаружи «кадра нет» неотличимо от «кадр не доехал», а чинится это
// по-разному. Подробности отказа скачивания пишет сам загрузчик.
if (string.IsNullOrEmpty(meta.StillUrl)) if (string.IsNullOrEmpty(meta.StillUrl))
{
logger.LogWarning(
"Кадр серии «{Episode}» не проставлен: источник не вернул ссылку",
meta.Title
);
return null; return null;
}
var downloaded = await downloader.DownloadAsync(meta.StillUrl, cancellationToken); var downloaded = await downloader.DownloadAsync(meta.StillUrl, cancellationToken);
if (downloaded is null) if (downloaded is null)
{
logger.LogWarning(
"Кадр серии «{Episode}» не проставлен: {Url} не скачался",
meta.Title,
meta.StillUrl
);
return null; return null;
}
var image = Image.Create(ImageCategory.EpisodeStill, downloaded.Extension, meta.Title); var image = Image.Create(ImageCategory.EpisodeStill, downloaded.Extension, meta.Title);
dbContext.Images.Add(image); dbContext.Images.Add(image);
@@ -117,7 +117,9 @@ public sealed class ShowMetadataApplier(
downloaded.Content, downloaded.Content,
cancellationToken cancellationToken
); );
logger.LogInformation( // Успех — отладочным уровнем: при массовом обогащении это строка на каждое шоу, а знать
// оператору интересно обратное — что постер не доехал.
logger.LogDebug(
"Постер для «{Show}» сохранён как изображение {ImageId}", "Постер для «{Show}» сохранён как изображение {ImageId}",
show.Name, show.Name,
image.Id image.Id
@@ -110,7 +110,11 @@ public sealed class TmdbMetadataProvider(
// оставляло бы шоу без постера, и это неотличимо от «постера нет вовсе». // оставляло бы шоу без постера, и это неотличимо от «постера нет вовсе».
var posterPath = var posterPath =
GetString(root, "poster_path") GetString(root, "poster_path")
?? await NeutralPosterPathAsync(movie, externalId, cancellationToken); ?? await NeutralImagePathAsync(
$"{Segment(movie)}/{externalId}",
"poster_path",
cancellationToken
);
return new ShowMetadata( return new ShowMetadata(
externalId, externalId,
@@ -126,19 +130,20 @@ public sealed class TmdbMetadataProvider(
} }
/// <summary> /// <summary>
/// Постер без привязки к языку: тот же запрос, но без <c>language</c>. Второе обращение к API /// Картинка без привязки к языку: тот же адрес, но без <c>language</c>. TMDb отдаёт постеры
/// делается только когда локализованного постера нет, — то есть редко. /// и кадры под запрошенный язык, и у контента без локализованной картинки поле пустое — хотя
/// язык-нейтральная у него есть. Второе обращение к API делается только в этом случае.
/// </summary> /// </summary>
private async Task<string?> NeutralPosterPathAsync( private async Task<string?> NeutralImagePathAsync(
bool movie, string path,
string externalId, string field,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var query = UsesBearer ? string.Empty : $"?api_key={Tmdb.ApiKey}"; var query = UsesBearer ? string.Empty : $"?api_key={Tmdb.ApiKey}";
var url = $"{Tmdb.BaseUrl}/{Segment(movie)}/{externalId}{query}"; var url = $"{Tmdb.BaseUrl}/{path}{query}";
using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken); using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken);
return doc is null ? null : GetString(doc.RootElement, "poster_path"); return doc is null ? null : GetString(doc.RootElement, field);
} }
/// <summary> /// <summary>
@@ -274,15 +279,28 @@ public sealed class TmdbMetadataProvider(
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
var url = $"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}{BaseQuery}"; var path = $"tv/{externalId}/season/{season}/episode/{episode}";
using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken); using var doc = await TryGetAsync(
httpFactory,
$"{Tmdb.BaseUrl}/{path}{BaseQuery}",
cancellationToken,
BearerToken
);
if (doc is null) if (doc is null)
return null; return null;
var root = doc.RootElement; var root = doc.RootElement;
// Кадр серии — та же история, что и с постером шоу: под русский язык его обычно нет,
// а язык-нейтральный есть. Добор идёт по серии, поэтому у сезона без локализованных
// кадров запросов вдвое больше — «Обновить серии» жмут руками, это терпимо.
var stillPath =
GetString(root, "still_path")
?? await NeutralImagePathAsync(path, "still_path", cancellationToken);
return new EpisodeMetadata( return new EpisodeMetadata(
GetString(root, "name") ?? "—", GetString(root, "name") ?? "—",
GetString(root, "overview"), GetString(root, "overview"),
StillUrl(GetString(root, "still_path")), StillUrl(stillPath),
DateFrom(GetString(root, "air_date")) DateFrom(GetString(root, "air_date"))
); );
} }
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute; using NSubstitute;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata; using TeleWave.Application.Metadata;
@@ -39,7 +40,8 @@ public class RefreshEpisodesTests
db, db,
resolver, resolver,
downloader ?? Substitute.For<IImageDownloader>(), downloader ?? Substitute.For<IImageDownloader>(),
store ?? Substitute.For<IImageStore>() store ?? Substitute.For<IImageStore>(),
NullLogger<RefreshShowEpisodesMetadataCommandHandler>.Instance
); );
/// <summary>Сериал с привязкой к источнику и одной серией на готовом ассете.</summary> /// <summary>Сериал с привязкой к источнику и одной серией на готовом ассете.</summary>
@@ -146,6 +146,26 @@ public class TmdbMetadataProviderTests
Assert.EndsWith("/neutral.jpg", show?.PosterUrl); Assert.EndsWith("/neutral.jpg", show?.PosterUrl);
} }
/// <summary>Кадр серии — та же история, что и с постером шоу: под русский язык его часто нет.</summary>
[Fact]
public async Task GetEpisode_WithoutLocalizedStill_FallsBackToNeutralOne()
{
var http = new FakeHttpClientFactory(url =>
FakeHttpClientFactory.Ok(
url.Contains("language=", StringComparison.Ordinal)
? """{"name":"Серия"}"""
: """{"name":"Серия","still_path":"/neutral.jpg"}"""
)
);
var episode = await Provider(http).GetEpisodeAsync("9", 2, 5, CancellationToken.None);
Assert.Equal(2, http.Urls.Count);
Assert.DoesNotContain("language=", http.LastUrl);
Assert.Contains("/tv/9/season/2/episode/5", http.LastUrl);
Assert.EndsWith("/neutral.jpg", episode?.StillUrl);
}
[Fact] [Fact]
public async Task GetShow_Movie_FallsBackToAnyReleaseCertification() public async Task GetShow_Movie_FallsBackToAnyReleaseCertification()
{ {