diff --git a/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs index b0dcd5b..1979507 100644 --- a/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs @@ -1,5 +1,6 @@ using LiteCqrs; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Application.Library; @@ -12,7 +13,8 @@ public sealed class RefreshShowEpisodesMetadataCommandHandler( IAppDbContext dbContext, IMetadataProviderResolver resolver, IImageDownloader downloader, - IImageStore imageStore + IImageStore imageStore, + ILogger logger ) : ICommandHandler> { public async Task> Handle( @@ -99,12 +101,27 @@ public sealed class RefreshShowEpisodesMetadataCommandHandler( CancellationToken cancellationToken ) { + // Как и с постером шоу: снаружи «кадра нет» неотличимо от «кадр не доехал», а чинится это + // по-разному. Подробности отказа скачивания пишет сам загрузчик. if (string.IsNullOrEmpty(meta.StillUrl)) + { + logger.LogWarning( + "Кадр серии «{Episode}» не проставлен: источник не вернул ссылку", + meta.Title + ); return null; + } var downloaded = await downloader.DownloadAsync(meta.StillUrl, cancellationToken); if (downloaded is null) + { + logger.LogWarning( + "Кадр серии «{Episode}» не проставлен: {Url} не скачался", + meta.Title, + meta.StillUrl + ); return null; + } var image = Image.Create(ImageCategory.EpisodeStill, downloaded.Extension, meta.Title); dbContext.Images.Add(image); diff --git a/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs b/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs index b960984..c3b1ede 100644 --- a/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs +++ b/backend/src/TeleWave.Application/Metadata/ShowMetadataApplier.cs @@ -117,7 +117,9 @@ public sealed class ShowMetadataApplier( downloaded.Content, cancellationToken ); - logger.LogInformation( + // Успех — отладочным уровнем: при массовом обогащении это строка на каждое шоу, а знать + // оператору интересно обратное — что постер не доехал. + logger.LogDebug( "Постер для «{Show}» сохранён как изображение {ImageId}", show.Name, image.Id diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs index 3d8b4b8..987bb84 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs @@ -110,7 +110,11 @@ public sealed class TmdbMetadataProvider( // оставляло бы шоу без постера, и это неотличимо от «постера нет вовсе». var posterPath = GetString(root, "poster_path") - ?? await NeutralPosterPathAsync(movie, externalId, cancellationToken); + ?? await NeutralImagePathAsync( + $"{Segment(movie)}/{externalId}", + "poster_path", + cancellationToken + ); return new ShowMetadata( externalId, @@ -126,19 +130,20 @@ public sealed class TmdbMetadataProvider( } /// - /// Постер без привязки к языку: тот же запрос, но без language. Второе обращение к API - /// делается только когда локализованного постера нет, — то есть редко. + /// Картинка без привязки к языку: тот же адрес, но без language. TMDb отдаёт постеры + /// и кадры под запрошенный язык, и у контента без локализованной картинки поле пустое — хотя + /// язык-нейтральная у него есть. Второе обращение к API делается только в этом случае. /// - private async Task NeutralPosterPathAsync( - bool movie, - string externalId, + private async Task NeutralImagePathAsync( + string path, + string field, CancellationToken cancellationToken ) { 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); - return doc is null ? null : GetString(doc.RootElement, "poster_path"); + return doc is null ? null : GetString(doc.RootElement, field); } /// @@ -274,15 +279,28 @@ public sealed class TmdbMetadataProvider( CancellationToken cancellationToken ) { - var url = $"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}/episode/{episode}{BaseQuery}"; - using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken); + var path = $"tv/{externalId}/season/{season}/episode/{episode}"; + using var doc = await TryGetAsync( + httpFactory, + $"{Tmdb.BaseUrl}/{path}{BaseQuery}", + cancellationToken, + BearerToken + ); if (doc is null) return null; var root = doc.RootElement; + + // Кадр серии — та же история, что и с постером шоу: под русский язык его обычно нет, + // а язык-нейтральный есть. Добор идёт по серии, поэтому у сезона без локализованных + // кадров запросов вдвое больше — «Обновить серии» жмут руками, это терпимо. + var stillPath = + GetString(root, "still_path") + ?? await NeutralImagePathAsync(path, "still_path", cancellationToken); + return new EpisodeMetadata( GetString(root, "name") ?? "—", GetString(root, "overview"), - StillUrl(GetString(root, "still_path")), + StillUrl(stillPath), DateFrom(GetString(root, "air_date")) ); } diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/RefreshEpisodesTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/RefreshEpisodesTests.cs index 2271ccd..5a23d66 100644 --- a/backend/tests/TeleWave.Application.Tests/Metadata/RefreshEpisodesTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Metadata/RefreshEpisodesTests.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Metadata; @@ -39,7 +40,8 @@ public class RefreshEpisodesTests db, resolver, downloader ?? Substitute.For(), - store ?? Substitute.For() + store ?? Substitute.For(), + NullLogger.Instance ); /// Сериал с привязкой к источнику и одной серией на готовом ассете. diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs index 1c66a11..be12920 100644 --- a/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Metadata/TmdbMetadataProviderTests.cs @@ -146,6 +146,26 @@ public class TmdbMetadataProviderTests Assert.EndsWith("/neutral.jpg", show?.PosterUrl); } + /// Кадр серии — та же история, что и с постером шоу: под русский язык его часто нет. + [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] public async Task GetShow_Movie_FallsBackToAnyReleaseCertification() {