using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Metadata;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using Xunit;
namespace TeleWave.Application.Tests.Media;
///
/// Массовое заведение фильмов: шоу на файл, метаданные из источника, файл в очередь. Фильмы
/// разбираются независимо — одна битая строка не должна отменять остальные сорок.
///
public class ImportMoviesTests
{
private const string Provider = "tmdb";
private static IMediaStorage StorageWith(params string[] files)
{
var storage = Substitute.For();
storage
.ListManualInbox(Arg.Any())
.Returns([
.. files.Select(f => new IMediaStorage.ManualInboxFile(
f,
Path.GetFileName(f),
1024
)),
]);
return storage;
}
private static IMetadataProviderResolver Resolver(ShowMetadata? metadata = null)
{
var provider = Substitute.For();
provider
.GetShowAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns(metadata);
var resolver = Substitute.For();
resolver.Resolve(Provider).Returns(provider);
resolver.AvailableKeys.Returns([Provider]);
return resolver;
}
private static async Task ImportAsync(
TestDb fixture,
IMediaStorage storage,
IMetadataProviderResolver resolver,
IMediaProcessingQueue queue,
params MovieImportItem[] items
)
{
await using var db = fixture.New();
var applier = new ShowMetadataApplier(
db,
resolver,
Substitute.For(),
Substitute.For(),
new GenreMatcher(db),
NullLogger.Instance
);
var result = await new ImportMoviesCommandHandler(
db,
new ManualInboxTaker(db, storage),
queue,
applier
).Handle(new ImportMoviesCommand(items, Provider), CancellationToken.None);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
return result.Value;
}
[Fact]
public async Task CreatesShowPerFile_AndSendsItToProcessing()
{
var fixture = new TestDb();
var storage = StorageWith("Gladiator.2000.BDRip.mkv");
var queue = Substitute.For();
var result = await ImportAsync(
fixture,
storage,
Resolver(new ShowMetadata("98", "Gladiator", 2000, "desc", null, [], null)),
queue,
new MovieImportItem("Gladiator.2000.BDRip.mkv", null, "Gladiator", 2000, "98")
);
Assert.Equal(1, result.Created);
Assert.Equal(1, result.Enriched);
Assert.Empty(result.Failed);
await using var db = fixture.New();
var show = await db.Shows.Include(s => s.Episodes).SingleAsync(CancellationToken.None);
Assert.Equal(ShowKind.Single, show.Kind);
Assert.Equal("Gladiator", show.Name);
Assert.Equal(2000, show.Year);
Assert.Single(show.Episodes);
// Файл ушёл в очередь обработки, а спутники из manual/ подчищены.
queue.Received(1).Enqueue(Arg.Any());
await storage
.Received(1)
.CleanupManualLeftoversAsync("Gladiator.2000.BDRip.mkv", Arg.Any());
}
[Fact]
public async Task NamesShowAsInTheSource_AndKeepsFileTitleAsOriginal()
{
// В таблице разбора «Название» — строка поиска: «Die Hard 2» там стоит ровно для того,
// чтобы найти «Крепкий орешек 2». В библиотеку должно попасть название источника,
// а латинское — остаться оригинальным.
var fixture = new TestDb();
var result = await ImportAsync(
fixture,
StorageWith("Die Hard 2 [1990 HDRip].avi"),
Resolver(
new ShowMetadata(
"42",
"Крепкий орешек 2",
1990,
"desc",
null,
[],
null,
null,
"Die Hard 2"
)
),
Substitute.For(),
new MovieImportItem("Die Hard 2 [1990 HDRip].avi", null, "Die Hard 2", 1990, "42")
);
Assert.Equal(1, result.Enriched);
await using var db = fixture.New();
var show = await db.Shows.SingleAsync(CancellationToken.None);
Assert.Equal("Крепкий орешек 2", show.Name);
Assert.Equal("Die Hard 2", show.OriginalName);
}
[Fact]
public async Task KeepsTypedTitleAsOriginal_WhenSourceHasNoOriginal()
{
// У OMDb оригинального названия нет — тогда оригинальным становится то, что вписали
// в таблице: по нему потом ищутся релизы.
var fixture = new TestDb();
var result = await ImportAsync(
fixture,
StorageWith("Spaun.1997.VHSRip.avi"),
Resolver(new ShowMetadata("5", "Спаун", 1997, null, null, [], null, null)),
Substitute.For(),
new MovieImportItem("Spaun.1997.VHSRip.avi", null, "Spaun", 1997, "5")
);
Assert.Equal(1, result.Enriched);
await using var db = fixture.New();
var show = await db.Shows.SingleAsync(CancellationToken.None);
Assert.Equal("Спаун", show.Name);
Assert.Equal("Spaun", show.OriginalName);
}
[Fact]
public async Task CreatesShowWithoutMetadata_WhenCandidateIsNotChosen()
{
// Строка, где источник ничего не дал: шоу с файлом уже полезно, метаданные дотянет
// массовое обогащение.
var fixture = new TestDb();
var result = await ImportAsync(
fixture,
StorageWith("Shou.Trumana.1998.BDRip.mkv"),
Resolver(),
Substitute.For(),
new MovieImportItem("Shou.Trumana.1998.BDRip.mkv", null, "Шоу Трумана", 1998, null)
);
Assert.Equal(1, result.Created);
Assert.Equal(0, result.Enriched);
Assert.Empty(result.Failed);
await using var db = fixture.New();
var show = await db.Shows.SingleAsync(CancellationToken.None);
Assert.Equal("Шоу Трумана", show.Name);
Assert.Null(show.MetadataExternalId);
}
[Fact]
public async Task AttachesAlreadyUploadedAsset_WithoutQueueingItTwice()
{
var fixture = new TestDb();
var asset = MediaAsset.Register("Die Hard 2.avi", ".avi", MediaSource.Upload);
await using (var seed = fixture.New())
{
seed.MediaAssets.Add(asset);
await seed.SaveChangesAsync(CancellationToken.None);
}
var queue = Substitute.For();
var result = await ImportAsync(
fixture,
StorageWith(),
Resolver(),
queue,
new MovieImportItem(null, asset.Id, "Die Hard 2", 1990, null)
);
Assert.Equal(1, result.Created);
// Загруженный файл встал в очередь ещё при загрузке — второй раз ставить нечего.
queue.DidNotReceive().Enqueue(Arg.Any());
await using var db = fixture.New();
var show = await db.Shows.Include(s => s.Episodes).SingleAsync(CancellationToken.None);
Assert.Equal(asset.Id, Assert.Single(show.Episodes).MediaAssetId);
}
[Fact]
public async Task ReportsMissingFile_AndImportsTheRest()
{
var fixture = new TestDb();
var result = await ImportAsync(
fixture,
StorageWith("Gladiator.2000.BDRip.mkv"),
Resolver(),
Substitute.For(),
new MovieImportItem("Ушёл.из.каталога.mkv", null, "Пропавший", null, null),
new MovieImportItem("Gladiator.2000.BDRip.mkv", null, "Gladiator", 2000, null)
);
Assert.Equal(1, result.Created);
var failure = Assert.Single(result.Failed);
Assert.Equal("Пропавший", failure.Title);
}
[Fact]
public async Task RejectsFileAlreadyInLibrary()
{
var fixture = new TestDb();
await using (var seed = fixture.New())
{
seed.MediaAssets.Add(
MediaAsset.Register("Gladiator.2000.BDRip.mkv", ".mkv", MediaSource.Upload)
);
await seed.SaveChangesAsync(CancellationToken.None);
}
var result = await ImportAsync(
fixture,
StorageWith("Gladiator.2000.BDRip.mkv"),
Resolver(),
Substitute.For(),
new MovieImportItem("Gladiator.2000.BDRip.mkv", null, "Gladiator", 2000, null)
);
Assert.Equal(0, result.Created);
Assert.Single(result.Failed);
}
[Fact]
public async Task AutoAttach_CreatesMovie_OnConfidentMatch()
{
var fixture = new TestDb();
var asset = MediaAsset.Register("Gladiator.2000.BDRip.mkv", ".mkv", MediaSource.Inbox);
await using (var seed = fixture.New())
{
seed.MediaAssets.Add(asset);
await seed.SaveChangesAsync(CancellationToken.None);
}
var provider = Substitute.For();
provider
.SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns([new MetadataCandidate("98", "Gladiator", 2000, null, null, ShowKind.Single)]);
provider
.GetShowAsync("98", Arg.Any(), Arg.Any())
.Returns(new ShowMetadata("98", "Gladiator", 2000, "desc", null, [], null));
var resolver = Substitute.For();
resolver.Resolve(Provider).Returns(provider);
resolver.AvailableKeys.Returns([Provider]);
await using (var db = fixture.New())
{
var applier = new ShowMetadataApplier(
db,
resolver,
Substitute.For(),
Substitute.For(),
new GenreMatcher(db),
NullLogger.Instance
);
var result = await new AutoAttachMovieCommandHandler(
db,
resolver,
new MovieMatcher(db, resolver),
applier
).Handle(
new AutoAttachMovieCommand(asset.Id, "Gladiator.2000.BDRip.mkv"),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
}
await using var check = fixture.New();
var show = await check.Shows.Include(s => s.Episodes).SingleAsync(CancellationToken.None);
Assert.Equal("Gladiator", show.Name);
Assert.Equal("98", show.MetadataExternalId);
Assert.Equal(asset.Id, Assert.Single(show.Episodes).MediaAssetId);
}
[Fact]
public async Task AutoAttach_DoesNothing_WhenMatchIsLoose()
{
// Файл остаётся ассетом без шоу — его разберёт человек в таблице разбора.
var fixture = new TestDb();
var asset = MediaAsset.Register("Voron.1994.TRIPLE.mkv", ".mkv", MediaSource.Inbox);
await using (var seed = fixture.New())
{
seed.MediaAssets.Add(asset);
await seed.SaveChangesAsync(CancellationToken.None);
}
var provider = Substitute.For();
provider
.SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any())
.Returns([new MetadataCandidate("7", "The Crow", 1994, null, null, ShowKind.Single)]);
var resolver = Substitute.For();
resolver.Resolve(Provider).Returns(provider);
resolver.AvailableKeys.Returns([Provider]);
await using var db = fixture.New();
var applier = new ShowMetadataApplier(
db,
resolver,
Substitute.For(),
Substitute.For(),
new GenreMatcher(db),
NullLogger.Instance
);
var result = await new AutoAttachMovieCommandHandler(
db,
resolver,
new MovieMatcher(db, resolver),
applier
).Handle(
new AutoAttachMovieCommand(asset.Id, "Voron.1994.TRIPLE.mkv"),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.False(result.Value);
Assert.Empty(await db.Shows.ToListAsync(CancellationToken.None));
}
}