diff --git a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs index b238a9b..5b52e3c 100644 --- a/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MetadataEndpoints.cs @@ -9,6 +9,7 @@ using TeleWave.Application.Metadata.RefreshEpisodes; using TeleWave.Application.Metadata.SearchShows; using TeleWave.Application.Metadata.SetShowPoster; using TeleWave.Application.Metadata.UpdateShowMetadata; +using TeleWave.Domain.Library; using TeleWave.Infrastructure.Identity; namespace TeleWave.Api.Endpoints; @@ -50,12 +51,13 @@ public static class MetadataEndpoints private static async Task Search( string provider, string query, + ShowKind kind, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( - new SearchShowMetadataQuery(provider, query), + new SearchShowMetadataQuery(provider, query, kind), cancellationToken ); return result.ToHttpResult(); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs index 67c2eee..a7cf4e0 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMetadataProvider.cs @@ -1,20 +1,40 @@ using TeleWave.Application.Metadata; +using TeleWave.Domain.Library; namespace TeleWave.Application.Common.Interfaces; -/// Источник метаданных о шоу/сериях (TMDb, OMDb). Реализация ходит во внешний API. +/// +/// Источник метаданных о шоу/сериях (TMDb, OMDb). Реализация ходит во внешний API. +/// +/// Тип шоу передаётся отдельным параметром, а не зашивается в externalId: у TMDb сериалы и +/// фильмы нумеруются в разных пространствах, и одно и то же число там означает разное. Хранить +/// в идентификаторе синтетический префикс не пришлось — тип и так известен на месте вызова, а +/// неизменяем, поэтому найденный при поиске идентификатор при любом +/// последующем обращении истолкуется тем же типом. +/// public interface IMetadataProvider { /// Ключ источника: «tmdb», «omdb». string Key { get; } + /// Кандидаты по названию среди контента указанного типа. Task> SearchShowsAsync( string query, + ShowKind kind, CancellationToken cancellationToken ); - Task GetShowAsync(string externalId, CancellationToken cancellationToken); + /// + /// Метаданные шоу. брать из самого шоу, а не из внешнего запроса: + /// иначе клиент может заставить записать сериалу метаданные одноимённого фильма. + /// + Task GetShowAsync( + string externalId, + ShowKind kind, + CancellationToken cancellationToken + ); + /// Метаданные серии. Только для — у остальных серий нет. Task GetEpisodeAsync( string externalId, int season, diff --git a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs index 2734bb3..706f734 100644 --- a/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/ApplyShowMetadata/ApplyShowMetadataCommandHandler.cs @@ -5,6 +5,7 @@ using TeleWave.Application.Common.Models; using TeleWave.Application.Library; using TeleWave.Application.Library.Genres; using TeleWave.Domain.Images; +using TeleWave.Domain.Library; namespace TeleWave.Application.Metadata.ApplyShowMetadata; @@ -27,11 +28,18 @@ public sealed class ApplyShowMetadataCommandHandler( if (show is null) return Result.Failure(ShowErrors.NotFound); + // Ролики метаданными не обогащаются: у них нет ни года, ни постера, а сотня роликов + // в поиске только мешала бы. Раньше это держалось на том, что UI такой кнопки не рисует. + if (show.Kind == ShowKind.Interstitial) + return Result.Failure(MetadataErrors.NotForInterstitials); + var provider = resolver.Resolve(command.Provider); if (provider is null) return Result.Failure(MetadataErrors.ProviderNotAvailable); - var meta = await provider.GetShowAsync(command.ExternalId, cancellationToken); + // Тип берём у шоу, а не из команды: иначе клиент смог бы заставить TMDb отдать метаданные + // одноимённого фильма для сериала — идентификаторы у них независимые. + var meta = await provider.GetShowAsync(command.ExternalId, show.Kind, cancellationToken); if (meta is null) return Result.Failure(MetadataErrors.NotFound); diff --git a/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs b/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs index 9481cde..f70c8ea 100644 --- a/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/FindMissingEpisodes/FindMissingEpisodesQueryHandler.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Application.Library; +using TeleWave.Domain.Library; namespace TeleWave.Application.Metadata.FindMissingEpisodes; @@ -23,6 +24,11 @@ public sealed class FindMissingEpisodesQueryHandler( if (show is null) return Result.Failure(ShowErrors.NotFound); + // Сезоны и серии есть только у сериала: у полнометражки и ролика запрос к источнику ушёл бы + // в /tv/{id} с идентификатором фильма и вернул бы либо пусто, либо чужие данные. + if (show.Kind != ShowKind.Series) + return Result.Failure(MetadataErrors.EpisodesForSeriesOnly); + if (string.IsNullOrEmpty(show.MetadataExternalId)) return Result.Failure(MetadataErrors.NoLinkedSource); diff --git a/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs b/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs index 25966f8..f9b92fc 100644 --- a/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs +++ b/backend/src/TeleWave.Application/Metadata/MetadataErrors.cs @@ -23,4 +23,14 @@ public static class MetadataErrors "Metadata.ProviderRequestFailed", "Источник метаданных вернул ошибку — проверьте API-ключ." ); + + public static readonly Error NotForInterstitials = Error.Validation( + "Metadata.NotForInterstitials", + "Ролики-врезки метаданными не обогащаются." + ); + + public static readonly Error EpisodesForSeriesOnly = Error.Validation( + "Metadata.EpisodesForSeriesOnly", + "Серии есть только у сериала." + ); } diff --git a/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs index 2d1768f..87f7469 100644 --- a/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/RefreshEpisodes/RefreshShowEpisodesMetadataCommandHandler.cs @@ -4,6 +4,7 @@ using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Models; using TeleWave.Application.Library; using TeleWave.Domain.Images; +using TeleWave.Domain.Library; namespace TeleWave.Application.Metadata.RefreshEpisodes; @@ -25,6 +26,10 @@ public sealed class RefreshShowEpisodesMetadataCommandHandler( if (show is null) return Result.Failure(ShowErrors.NotFound); + // Серии есть только у сериала — см. FindMissingEpisodesQueryHandler. + if (show.Kind != ShowKind.Series) + return Result.Failure(MetadataErrors.EpisodesForSeriesOnly); + if (string.IsNullOrEmpty(show.MetadataExternalId)) return Result.Failure(MetadataErrors.NoLinkedSource); diff --git a/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQuery.cs b/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQuery.cs index b69dba0..deeaa5e 100644 --- a/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQuery.cs +++ b/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQuery.cs @@ -1,7 +1,12 @@ using LiteCqrs; using TeleWave.Application.Common.Models; +using TeleWave.Domain.Library; namespace TeleWave.Application.Metadata.SearchShows; -public sealed record SearchShowMetadataQuery(string Provider, string Query) +/// +/// Поиск кандидатов в источнике. определяет, среди чего искать: у TMDb +/// сериалы и фильмы лежат в разных пространствах идентификаторов. +/// +public sealed record SearchShowMetadataQuery(string Provider, string Query, ShowKind Kind) : IQuery>>; diff --git a/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs b/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs index 39adadb..c0231e5 100644 --- a/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs +++ b/backend/src/TeleWave.Application/Metadata/SearchShows/SearchShowMetadataQueryHandler.cs @@ -23,7 +23,11 @@ public sealed class SearchShowMetadataQueryHandler(IMetadataProviderResolver res try { - var results = await provider.SearchShowsAsync(query.Query, cancellationToken); + var results = await provider.SearchShowsAsync( + query.Query, + query.Kind, + cancellationToken + ); return Result.Success(results); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) diff --git a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs index c171011..6aa07da 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/OmdbMetadataProvider.cs @@ -20,13 +20,16 @@ public sealed class OmdbMetadataProvider( public async Task> SearchShowsAsync( string query, + ShowKind kind, CancellationToken cancellationToken ) { - // Тип в запросе не фиксируем: imdbID уникален и для фильма, и для сериала, а GetShowAsync - // ходит по нему же — значит полнометражки находятся и обогащаются тем же путём, без - // разделения пространств идентификаторов (в отличие от TMDb, где tv и movie нумеруются врозь). - var url = $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&s={Uri.EscapeDataString(query)}"; + // Тип сужает выдачу, но не влияет на идентификатор: imdbID уникален и для фильма, и для + // сериала, поэтому GetShowAsync ходит по нему одинаково — в отличие от TMDb, где tv и movie + // нумеруются врозь. + var type = kind == ShowKind.Series ? "series" : "movie"; + var url = + $"{_omdb.BaseUrl}/?apikey={_omdb.ApiKey}&type={type}&s={Uri.EscapeDataString(query)}"; using var doc = await GetAsync(httpFactory, url, cancellationToken); if (!doc.RootElement.TryGetProperty("Search", out var search)) return []; @@ -38,9 +41,10 @@ public sealed class OmdbMetadataProvider( if (id is null) continue; - // Отдельные серии в выдаче — шум: шоу из них не собирают, а список они забивают. - var kind = KindFrom(Clean(GetString(item, "Type"))); - if (kind is null) + // Тип читаем из ответа, а не подставляем запрошенный: фильтр в запросе OMDb соблюдает, + // но отдельные серии (Type = episode) иногда просачиваются, а шоу из них не собирают. + var found = KindFrom(Clean(GetString(item, "Type"))); + if (found is null) continue; list.Add( @@ -50,15 +54,17 @@ public sealed class OmdbMetadataProvider( YearFrom(GetString(item, "Year")), null, Clean(GetString(item, "Poster")), - kind + found ) ); } return list; } + /// Тип здесь не используется: imdbID однозначен для фильмов и сериалов одинаково. public async Task GetShowAsync( string externalId, + ShowKind kind, CancellationToken cancellationToken ) { diff --git a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs index f72bfb8..8762293 100644 --- a/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs +++ b/backend/src/TeleWave.Infrastructure/Metadata/TmdbMetadataProvider.cs @@ -22,11 +22,13 @@ public sealed class TmdbMetadataProvider( public async Task> SearchShowsAsync( string query, + ShowKind kind, CancellationToken cancellationToken ) { + var movie = IsMovie(kind); var url = - $"{Tmdb.BaseUrl}/search/tv?api_key={Tmdb.ApiKey}&language={_options.Language}" + $"{Tmdb.BaseUrl}/search/{Segment(movie)}?api_key={Tmdb.ApiKey}&language={_options.Language}" + $"&include_adult=false&query={Uri.EscapeDataString(query)}"; using var doc = await GetAsync(httpFactory, url, cancellationToken); if (!doc.RootElement.TryGetProperty("results", out var results)) @@ -41,56 +43,117 @@ public sealed class TmdbMetadataProvider( list.Add( new MetadataCandidate( id.Value.ToString(CultureInfo.InvariantCulture), - GetString(item, "name") ?? "—", - YearFrom(GetString(item, "first_air_date")), + GetString(item, TitleField(movie)) ?? "—", + YearFrom(GetString(item, DateField(movie))), GetString(item, "overview"), PosterUrl(GetString(item, "poster_path")), - // Поиск идёт по /search/tv, поэтому здесь всегда сериалы: полнометражки TMDb - // живут в отдельном пространстве идентификаторов и пока не поддержаны. - ShowKind.Series + movie ? ShowKind.Single : ShowKind.Series ) ); } return list; } + /// + /// Среди чего искать. Сериал — это , всё остальное (полнометражка, + /// ролик-врезка) ищется среди фильмов: у ролика единственная «серия», и сезонов у него нет. + /// + private static bool IsMovie(ShowKind kind) => kind != ShowKind.Series; + + private static string Segment(bool movie) => movie ? "movie" : "tv"; + + // У фильмов и сериалов TMDb называет одни и те же вещи по-разному. + private static string TitleField(bool movie) => movie ? "title" : "name"; + + private static string DateField(bool movie) => movie ? "release_date" : "first_air_date"; + public async Task GetShowAsync( string externalId, + ShowKind kind, CancellationToken cancellationToken ) { - // content_ratings подвешиваем к тому же запросу — отдельного обращения к API не требуется. + var movie = IsMovie(kind); + + // Сертификацию подвешиваем к тому же запросу — отдельного обращения к API не требуется. + // У фильмов и сериалов она лежит в разных разделах, поэтому и append, и разбор разные. var url = - $"{Tmdb.BaseUrl}/tv/{externalId}?api_key={Tmdb.ApiKey}&language={_options.Language}" - + "&append_to_response=content_ratings"; + $"{Tmdb.BaseUrl}/{Segment(movie)}/{externalId}" + + $"?api_key={Tmdb.ApiKey}&language={_options.Language}" + + $"&append_to_response={(movie ? "release_dates" : "content_ratings")}"; using var doc = await TryGetAsync(httpFactory, url, cancellationToken); if (doc is null) return null; var root = doc.RootElement; return new ShowMetadata( externalId, - GetString(root, "name") ?? "—", - YearFrom(GetString(root, "first_air_date")), + GetString(root, TitleField(movie)) ?? "—", + YearFrom(GetString(root, DateField(movie))), GetString(root, "overview"), PosterUrl(GetString(root, "poster_path")), GenresFrom(root), - CertificationFrom(root) + movie ? MovieCertification(root) : TvCertification(root) ); } + /// Код прокатного релиза в справочнике типов релизов TMDb. + private const int TheatricalReleaseType = 3; + /// - /// Возрастная сертификация из content_ratings: перебираем страны в порядке приоритета - /// () и берём первую непустую. Отдаём написание - /// источника как есть — приводит его к шкале приложение (ContentRating). - /// - /// Пустая строка в rating — обычное дело: страна в списке есть, а рейтинга у неё нет, - /// и такую запись надо пропускать, а не считать ответом. + /// Сертификация сериала: в content_ratings на страну приходится одна запись, рейтинг + /// лежит прямо в ней. /// - private string? CertificationFrom(JsonElement root) + private string? TvCertification(JsonElement root) => + FirstCertification(root, "content_ratings", item => Trimmed(GetString(item, "rating"))); + + /// + /// Сертификация фильма: в release_dates на страну приходится список релизов, и + /// certification проставлена не у всех. Прокатный релиз предпочитаем прочим — цифровые и + /// телевизионные переиздания иногда несут собственную, более позднюю маркировку. + /// + private string? MovieCertification(JsonElement root) => + FirstCertification( + root, + "release_dates", + item => + { + if ( + !item.TryGetProperty("release_dates", out var releases) + || releases.ValueKind != JsonValueKind.Array + ) + return null; + + string? anyRelease = null; + foreach (var release in releases.EnumerateArray()) + { + if (Trimmed(GetString(release, "certification")) is not { } certification) + continue; + if (GetInt(release, "type") == TheatricalReleaseType) + return certification; + anyRelease ??= certification; + } + return anyRelease; + } + ); + + /// + /// Общая часть обеих форм: перебираем страны в порядке приоритета + /// () и отдаём первое непустое значение, + /// которое вытащит из записи страны. Написание источника не трогаем — + /// приводит его к шкале приложение (ContentRating). + /// + /// Пустое значение — обычное дело: страна в списке есть, а сертификации у неё нет, и такую + /// запись надо пропускать, а не считать ответом. + /// + private string? FirstCertification( + JsonElement root, + string section, + Func select + ) { if ( - !root.TryGetProperty("content_ratings", out var ratings) - || !ratings.TryGetProperty("results", out var results) + !root.TryGetProperty(section, out var block) + || !block.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array ) return null; @@ -108,14 +171,17 @@ public sealed class TmdbMetadataProvider( ) continue; - if (GetString(item, "rating") is { Length: > 0 } rating && rating.Trim().Length > 0) - return rating.Trim(); + if (select(item) is { } value) + return value; } } return null; } + private static string? Trimmed(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + /// /// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор /// (tmdb:28), затем локализованное название. Идентификатор точнее, название — запасной diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/ApplyShowMetadataTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/ApplyShowMetadataTests.cs new file mode 100644 index 0000000..a6467b4 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Metadata/ApplyShowMetadataTests.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Library.Genres; +using TeleWave.Application.Metadata; +using TeleWave.Application.Metadata.ApplyShowMetadata; +using TeleWave.Application.Tests.Support; +using TeleWave.Domain.Library; +using Xunit; + +namespace TeleWave.Application.Tests.Metadata; + +public class ApplyShowMetadataTests +{ + [Theory] + [InlineData(ShowKind.Series)] + [InlineData(ShowKind.Single)] + public async Task PassesShowKindToProvider(ShowKind kind) + { + // Тип определяет, в какое пространство идентификаторов пойдёт TMDb, и берётся он у самого + // шоу, а не из команды: иначе запросом можно было бы записать сериалу метаданные + // одноимённого фильма. + var arranged = await ArrangeAsync(kind); + Respond(arranged.Provider, new ShowMetadata("42", "A", 2000, null, null)); + + var result = await ApplyAsync(arranged); + + Assert.True(result); + await arranged + .Provider.Received(1) + .GetShowAsync("42", kind, Arg.Any()); + } + + [Fact] + public async Task AppliesRecognizedRating() + { + var arranged = await ArrangeAsync(ShowKind.Single); + Respond(arranged.Provider, new ShowMetadata("42", "A", 2000, null, null, null, "PG-13")); + + await ApplyAsync(arranged); + + Assert.Equal(ShowAudience.Pg13, await SavedAudienceAsync(arranged)); + } + + [Fact] + public async Task KeepsRating_WhenSourceGivesNothingUsable() + { + // «Not Rated» — это отсутствие данных. Снять им проставленный рейтинг нельзя: обновление + // метаданных тихо открыло бы взрослому шоу дорогу в детское время. + var arranged = await ArrangeAsync(ShowKind.Single, ShowAudience.R); + Respond(arranged.Provider, new ShowMetadata("42", "A", 2000, null, null, null, "Not Rated")); + + await ApplyAsync(arranged); + + Assert.Equal(ShowAudience.R, await SavedAudienceAsync(arranged)); + } + + [Fact] + public async Task Fails_ForInterstitial() + { + var arranged = await ArrangeAsync(ShowKind.Interstitial); + + Assert.False(await ApplyAsync(arranged)); + await arranged + .Provider.DidNotReceive() + .GetShowAsync( + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } + + private static void Respond(IMetadataProvider provider, ShowMetadata meta) => + provider + .GetShowAsync("42", Arg.Any(), Arg.Any()) + .Returns(meta); + + private static async Task ApplyAsync(Arranged arranged) + { + await using var db = arranged.Fixture.New(); + var handler = new ApplyShowMetadataCommandHandler( + db, + arranged.Resolver, + Substitute.For(), + Substitute.For(), + new GenreMatcher(db) + ); + + var result = await handler.Handle( + new ApplyShowMetadataCommand(arranged.ShowId, "tmdb", "42"), + CancellationToken.None + ); + + // В обычном пути сохраняет UnitOfWorkBehavior; при прямом вызове хендлера его нет. + if (result.IsSuccess) + await db.SaveChangesAsync(CancellationToken.None); + return result.IsSuccess; + } + + private static async Task SavedAudienceAsync(Arranged arranged) + { + await using var db = arranged.Fixture.New(); + return (await db.Shows.FirstAsync(s => s.Id == arranged.ShowId)).Audience; + } + + private sealed record Arranged( + TestDb Fixture, + IMetadataProvider Provider, + IMetadataProviderResolver Resolver, + Guid ShowId + ); + + private static async Task ArrangeAsync(ShowKind kind, ShowAudience? audience = null) + { + var fixture = new TestDb(); + var show = Show.Create("A", kind); + show.SetAudience(audience); + + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var provider = Substitute.For(); + var resolver = Substitute.For(); + resolver.Resolve("tmdb").Returns(provider); + + return new Arranged(fixture, provider, resolver, show.Id); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Metadata/FindMissingEpisodesTests.cs b/backend/tests/TeleWave.Application.Tests/Metadata/FindMissingEpisodesTests.cs index 7498cac..3fa816b 100644 --- a/backend/tests/TeleWave.Application.Tests/Metadata/FindMissingEpisodesTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Metadata/FindMissingEpisodesTests.cs @@ -50,6 +50,29 @@ public class FindMissingEpisodesTests Assert.Equal([2, 3], s2.Missing); } + [Fact] + public async Task Fails_ForNonSeries() + { + // У полнометражки сезонов нет, и запрос ушёл бы в /tv/{id} с идентификатором фильма. + var fixture = new TestDb(); + var show = Show.Create("A", ShowKind.Single); + show.ApplyMetadata("tmdb", "123", null, 2000, null); + await using (var seed = fixture.New()) + { + seed.Shows.Add(show); + await seed.SaveChangesAsync(CancellationToken.None); + } + + var resolver = Substitute.For(); + await using var db = fixture.New(); + var result = await new FindMissingEpisodesQueryHandler(db, resolver).Handle( + new FindMissingEpisodesQuery(show.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + } + [Fact] public async Task Fails_WhenNoLinkedSource() { diff --git a/frontend/src/features/admin/shows/ShowMetadataCard.tsx b/frontend/src/features/admin/shows/ShowMetadataCard.tsx index 4cadbe8..4595eb8 100644 --- a/frontend/src/features/admin/shows/ShowMetadataCard.tsx +++ b/frontend/src/features/admin/shows/ShowMetadataCard.tsx @@ -69,7 +69,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged const searchTerm = originalName.trim() || name.trim() const search = useMutation({ - mutationFn: () => searchMetadata(effectiveProvider, searchTerm), + mutationFn: () => searchMetadata(effectiveProvider, searchTerm, show.kind), onSuccess: (data) => { setResults(data) setSearched(true) diff --git a/frontend/src/features/admin/shows/api.ts b/frontend/src/features/admin/shows/api.ts index 318b3e8..c926df0 100644 --- a/frontend/src/features/admin/shows/api.ts +++ b/frontend/src/features/admin/shows/api.ts @@ -73,8 +73,9 @@ export function getMetadataProviders() { return apiRequest('/admin/metadata/providers') } -export function searchMetadata(provider: string, query: string) { - const q = new URLSearchParams({ provider, query }) +/** `kind` определяет, среди чего искать: у TMDb сериалы и фильмы — разные пространства id. */ +export function searchMetadata(provider: string, query: string, kind: ShowKind) { + const q = new URLSearchParams({ provider, query, kind }) return apiRequest(`/admin/metadata/search?${q.toString()}`) }