using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Metadata; using TeleWave.Application.Metadata.RefreshEpisodes; using TeleWave.Application.Tests.Support; using TeleWave.Domain.Library; using TeleWave.Domain.Media; using Xunit; namespace TeleWave.Application.Tests.Metadata; /// /// Довыгрузка метаданных серий: что берётся за номер серии, что происходит с кадром и когда /// операция отказывается работать вовсе. /// public class RefreshEpisodesTests { private static EpisodeMetadata Meta(string title, string? stillUrl = null) => new(title, "Описание", stillUrl, new DateOnly(2005, 3, 20)); private static (IMetadataProviderResolver Resolver, IMetadataProvider Provider) ProviderFor( string key ) { var provider = Substitute.For(); var resolver = Substitute.For(); resolver.Resolve(key).Returns(provider); return (resolver, provider); } private static RefreshShowEpisodesMetadataCommandHandler Handler( IAppDbContext db, IMetadataProviderResolver resolver, IImageDownloader? downloader = null, IImageStore? store = null ) => new( db, resolver, downloader ?? Substitute.For(), store ?? Substitute.For(), NullLogger.Instance ); /// Сериал с привязкой к источнику и одной серией на готовом ассете. private static (Show Show, MediaAsset Asset) Linked(string fileName, bool withNumbers) { var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload); var show = Show.Create("Симпсоны", ShowKind.Series); show.ApplyMetadata("tmdb", "456", null, 2005, null); var episode = show.AddEpisode(asset.Id); if (withNumbers) episode.SetNumbers(16, 21); return (show, asset); } [Fact] public async Task AppliesMetadata_AndSavesStillToRegistry() { var fixture = new TestDb(); var (show, asset) = Linked("s16e21.mkv", withNumbers: true); await using (var seed = fixture.New()) { seed.MediaAssets.Add(asset); seed.Shows.Add(show); await seed.SaveChangesAsync(CancellationToken.None); } var (resolver, provider) = ProviderFor("tmdb"); provider .GetEpisodeAsync("456", 16, 21, Arg.Any()) .Returns(Meta("The Father, the Son", "https://img/still.jpg")); var downloader = Substitute.For(); downloader .DownloadAsync("https://img/still.jpg", Arg.Any()) .Returns(new DownloadedImage([1, 2, 3], ".jpg")); var store = Substitute.For(); await using var db = fixture.New(); var result = await Handler(db, resolver, downloader, store) .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(1, result.Value); await using var check = fixture.New(); var stored = await check.Shows.Include(s => s.Episodes).SingleAsync(CancellationToken.None); var episode = stored.Episodes.Single(); Assert.Equal("The Father, the Son", episode.Title); Assert.NotNull(episode.StillImageId); // Кадр попадает в общий реестр изображений, а не в своё файловое поле. Assert.Equal(1, await check.Images.CountAsync(CancellationToken.None)); await store .Received(1) .SaveAsync( episode.StillImageId!.Value, ".jpg", Arg.Any(), Arg.Any() ); } [Fact] public async Task TakesNumbersFromFileName_WhenNotSet() { var fixture = new TestDb(); var (show, asset) = Linked("Simpsons.S16E21.1080p.mkv", withNumbers: false); await using (var seed = fixture.New()) { seed.MediaAssets.Add(asset); seed.Shows.Add(show); await seed.SaveChangesAsync(CancellationToken.None); } var (resolver, provider) = ProviderFor("tmdb"); provider .GetEpisodeAsync("456", 16, 21, Arg.Any()) .Returns(Meta("Разобрано по имени")); await using var db = fixture.New(); var result = await Handler(db, resolver) .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None); Assert.Equal(1, result.Value); await using var check = fixture.New(); var episode = (await check.Shows.Include(s => s.Episodes).SingleAsync()).Episodes.Single(); // Разобранные номера тут же сохраняются: второй раз имя файла разбирать незачем. Assert.Equal(16, episode.Season); Assert.Equal(21, episode.Episode); } [Fact] public async Task SkipsEpisode_WhenNumbersUnknownOrSourceSilent() { var fixture = new TestDb(); var unparsable = MediaAsset.Register("случайное имя.mkv", ".mkv", MediaSource.Upload); var known = MediaAsset.Register("s01e02.mkv", ".mkv", MediaSource.Upload); var show = Show.Create("Сериал", ShowKind.Series); show.ApplyMetadata("tmdb", "1", null, null, null); show.AddEpisode(unparsable.Id); show.AddEpisode(known.Id); await using (var seed = fixture.New()) { seed.MediaAssets.AddRange(unparsable, known); seed.Shows.Add(show); await seed.SaveChangesAsync(CancellationToken.None); } var (resolver, provider) = ProviderFor("tmdb"); // Источник знает не всё: на распознанную серию он отвечает пустотой. provider .GetEpisodeAsync("1", Arg.Any(), Arg.Any(), Arg.Any()) .Returns((EpisodeMetadata?)null); await using var db = fixture.New(); var result = await Handler(db, resolver) .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None); Assert.True(result.IsSuccess); Assert.Equal(0, result.Value); // К нераспознанной серии источник не ходит вовсе — незачем. await provider .Received(1) .GetEpisodeAsync("1", Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task KeepsMetadata_WhenStillCannotBeDownloaded() { var fixture = new TestDb(); var (show, asset) = Linked("s01e01.mkv", withNumbers: true); await using (var seed = fixture.New()) { seed.MediaAssets.Add(asset); seed.Shows.Add(show); await seed.SaveChangesAsync(CancellationToken.None); } var (resolver, provider) = ProviderFor("tmdb"); provider .GetEpisodeAsync("456", Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Meta("Без кадра", "https://img/broken.jpg")); var downloader = Substitute.For(); downloader .DownloadAsync(Arg.Any(), Arg.Any()) .Returns((DownloadedImage?)null); await using var db = fixture.New(); var result = await Handler(db, resolver, downloader) .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None); // Недоступный кадр не повод отменять текст: серия обновлена, картинки просто нет. Assert.Equal(1, result.Value); await using var check = fixture.New(); var episode = (await check.Shows.Include(s => s.Episodes).SingleAsync()).Episodes.Single(); Assert.Equal("Без кадра", episode.Title); Assert.Null(episode.StillImageId); Assert.Equal(0, await check.Images.CountAsync(CancellationToken.None)); } [Fact] public async Task Fails_WhenShowIsMissingOrNotSeriesOrUnlinked() { var fixture = new TestDb(); var movie = Show.Create("Фильм", ShowKind.Single); var unlinked = Show.Create("Сериал без источника", ShowKind.Series); await using (var seed = fixture.New()) { seed.Shows.AddRange(movie, unlinked); await seed.SaveChangesAsync(CancellationToken.None); } var (resolver, _) = ProviderFor("tmdb"); await using var db = fixture.New(); var handler = Handler(db, resolver); var missing = await handler.Handle( new RefreshShowEpisodesMetadataCommand(Guid.NewGuid()), CancellationToken.None ); var notSeries = await handler.Handle( new RefreshShowEpisodesMetadataCommand(movie.Id), CancellationToken.None ); var noSource = await handler.Handle( new RefreshShowEpisodesMetadataCommand(unlinked.Id), CancellationToken.None ); Assert.False(missing.IsSuccess); Assert.Equal(MetadataErrors.EpisodesForSeriesOnly, notSeries.Error); Assert.Equal(MetadataErrors.NoLinkedSource, noSource.Error); } [Fact] public async Task Fails_WhenLinkedProviderIsNotConfigured() { var fixture = new TestDb(); var show = Show.Create("Сериал", ShowKind.Series); show.ApplyMetadata("omdb", "tt1", null, null, null); await using (var seed = fixture.New()) { seed.Shows.Add(show); await seed.SaveChangesAsync(CancellationToken.None); } var resolver = Substitute.For(); // Явно: NSubstitute сам подставляет заглушку вместо интерфейсного null. resolver.Resolve(Arg.Any()).Returns((IMetadataProvider?)null); await using var db = fixture.New(); var result = await Handler(db, resolver) .Handle(new RefreshShowEpisodesMetadataCommand(show.Id), CancellationToken.None); Assert.Equal(MetadataErrors.ProviderNotAvailable, result.Error); } }