Implement movie import functionality and enhance media processing
ci / build-backend (push) Successful in 1m34s
ci / build-frontend (push) Successful in 54s
ci / tests (push) Successful in 1m43s
ci / sonar (push) Successful in 6m45s

Added new endpoints for matching and importing movies, allowing for automated processing of film files from the inbox. Introduced logic to parse file names into titles and years, and integrated metadata matching to streamline the import process. Updated the MediaOptions to include a configuration for automatic movie creation from recognized files. Enhanced the InboxScanner to attempt movie attachment upon asset registration, improving user experience and efficiency. Updated documentation to reflect these new features and their usage.
This commit is contained in:
Leonid Pershin
2026-07-28 03:02:30 +03:00
parent e48838b69d
commit d859a9894c
25 changed files with 2144 additions and 4 deletions
@@ -0,0 +1,304 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.Genres;
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;
/// <summary>
/// Массовое заведение фильмов: шоу на файл, метаданные из источника, файл в очередь. Фильмы
/// разбираются независимо — одна битая строка не должна отменять остальные сорок.
/// </summary>
public class ImportMoviesTests
{
private const string Provider = "tmdb";
private static IMediaStorage StorageWith(params string[] files)
{
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.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<IMetadataProvider>();
provider
.GetShowAsync(Arg.Any<string>(), Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
.Returns(metadata);
var resolver = Substitute.For<IMetadataProviderResolver>();
resolver.Resolve(Provider).Returns(provider);
resolver.AvailableKeys.Returns([Provider]);
return resolver;
}
private static async Task<ImportMoviesResultDto> 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<IImageDownloader>(),
Substitute.For<IImageStore>(),
new GenreMatcher(db)
);
var result = await new ImportMoviesCommandHandler(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<IMediaProcessingQueue>();
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<Guid>());
await storage
.Received(1)
.CleanupManualLeftoversAsync("Gladiator.2000.BDRip.mkv", Arg.Any<CancellationToken>());
}
[Fact]
public async Task CreatesShowWithoutMetadata_WhenCandidateIsNotChosen()
{
// Строка, где источник ничего не дал: шоу с файлом уже полезно, метаданные дотянет
// массовое обогащение.
var fixture = new TestDb();
var result = await ImportAsync(
fixture,
StorageWith("Shou.Trumana.1998.BDRip.mkv"),
Resolver(),
Substitute.For<IMediaProcessingQueue>(),
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<IMediaProcessingQueue>();
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<Guid>());
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<IMediaProcessingQueue>(),
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<IMediaProcessingQueue>(),
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<IMetadataProvider>();
provider
.SearchShowsAsync(Arg.Any<string>(), Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
.Returns([new MetadataCandidate("98", "Gladiator", 2000, null, null, ShowKind.Single)]);
provider
.GetShowAsync("98", Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
.Returns(new ShowMetadata("98", "Gladiator", 2000, "desc", null, [], null));
var resolver = Substitute.For<IMetadataProviderResolver>();
resolver.Resolve(Provider).Returns(provider);
resolver.AvailableKeys.Returns([Provider]);
await using (var db = fixture.New())
{
var applier = new ShowMetadataApplier(
db,
resolver,
Substitute.For<IImageDownloader>(),
Substitute.For<IImageStore>(),
new GenreMatcher(db)
);
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<IMetadataProvider>();
provider
.SearchShowsAsync(Arg.Any<string>(), Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
.Returns([new MetadataCandidate("7", "The Crow", 1994, null, null, ShowKind.Single)]);
var resolver = Substitute.For<IMetadataProviderResolver>();
resolver.Resolve(Provider).Returns(provider);
resolver.AvailableKeys.Returns([Provider]);
await using var db = fixture.New();
var applier = new ShowMetadataApplier(
db,
resolver,
Substitute.For<IImageDownloader>(),
Substitute.For<IImageStore>(),
new GenreMatcher(db)
);
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));
}
}
@@ -0,0 +1,214 @@
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Metadata;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using Xunit;
namespace TeleWave.Application.Tests.Media;
/// <summary>
/// Подбор фильма по имени файла. Главное свойство то же, что у массового обогащения: уверенным
/// считается только дословное совпадение — чужой постер у фильма в эфире хуже, чем его отсутствие.
/// </summary>
public class MovieMatcherTests
{
private static IMetadataProvider ProviderWith(params MetadataCandidate[] candidates)
{
var provider = Substitute.For<IMetadataProvider>();
provider
.SearchShowsAsync(Arg.Any<string>(), Arg.Any<ShowKind>(), Arg.Any<CancellationToken>())
.Returns(candidates);
return provider;
}
private static IMetadataProviderResolver Resolver(IMetadataProvider provider)
{
var resolver = Substitute.For<IMetadataProviderResolver>();
resolver.Resolve("tmdb").Returns(provider);
resolver.AvailableKeys.Returns(["tmdb"]);
return resolver;
}
[Fact]
public async Task Confident_WhenTitleAndYearMatch()
{
var fixture = new TestDb();
var provider = ProviderWith(
new MetadataCandidate("42", "Die Hard 2", 1990, null, null, ShowKind.Single)
);
await using var db = fixture.New();
var matcher = new MovieMatcher(db, Resolver(provider));
var rows = await matcher.MatchAsync(
["Die Hard 2 [1990 HDRip].avi"],
"tmdb",
CancellationToken.None
);
var row = Assert.Single(rows);
Assert.Equal(MovieMatchStatus.Confident, row.Status);
Assert.Equal("42", row.PickedExternalId);
Assert.Equal("Die Hard 2", row.ParsedTitle);
Assert.Equal(1990, row.ParsedYear);
}
[Fact]
public async Task Confident_WhenYearIsOffByOne()
{
// Год проката и год производства у релизов расходятся на год — это норма, а не промах.
var fixture = new TestDb();
var provider = ProviderWith(
new MetadataCandidate("7", "The Crow", 1994, null, null, ShowKind.Single)
);
await using var db = fixture.New();
var matcher = new MovieMatcher(db, Resolver(provider));
var rows = await matcher.MatchAsync(
["The Crow 1993 BDRip"],
"tmdb",
CancellationToken.None
);
Assert.Equal(MovieMatchStatus.Confident, Assert.Single(rows).Status);
}
[Fact]
public async Task Uncertain_WhenYearIsFarOff()
{
var fixture = new TestDb();
var provider = ProviderWith(
new MetadataCandidate("7", "The Crow", 1994, null, null, ShowKind.Single)
);
await using var db = fixture.New();
var matcher = new MovieMatcher(db, Resolver(provider));
var rows = await matcher.MatchAsync(
["The Crow 2024 BDRip"],
"tmdb",
CancellationToken.None
);
var row = Assert.Single(rows);
Assert.Equal(MovieMatchStatus.Uncertain, row.Status);
Assert.Null(row.PickedExternalId);
// Кандидаты всё равно показываются: выбрать из них — секунда, искать заново — минута.
Assert.NotEmpty(row.Candidates);
}
[Fact]
public async Task Uncertain_WhenTwoCandidatesShareTitleAndYear()
{
var fixture = new TestDb();
var provider = ProviderWith(
new MetadataCandidate("1", "Gladiator", 2000, null, null, ShowKind.Single),
new MetadataCandidate("2", "Gladiator", 2000, null, null, ShowKind.Single)
);
await using var db = fixture.New();
var matcher = new MovieMatcher(db, Resolver(provider));
var rows = await matcher.MatchAsync(
["Gladiator.2000.BDRip"],
"tmdb",
CancellationToken.None
);
Assert.Equal(MovieMatchStatus.Uncertain, Assert.Single(rows).Status);
}
[Fact]
public async Task NotFound_WhenProviderReturnsNothing()
{
// Транслит: источник по «Shou Trumana» не найдёт ничего, и это отдельный статус — строку
// надо не выбрать, а переписать.
var fixture = new TestDb();
await using var db = fixture.New();
var matcher = new MovieMatcher(db, Resolver(ProviderWith()));
var rows = await matcher.MatchAsync(
["Shou.Trumana.1998.BDRip.AVC.mkv"],
"tmdb",
CancellationToken.None
);
var row = Assert.Single(rows);
Assert.Equal(MovieMatchStatus.NotFound, row.Status);
Assert.Empty(row.Candidates);
}
[Fact]
public async Task AlreadyInLibrary_WhenExternalIdIsTaken()
{
var fixture = new TestDb();
var existing = Show.Create("Гладиатор", ShowKind.Single);
existing.ApplyMetadata("tmdb", "98", null, 2000, null);
await using (var seed = fixture.New())
{
seed.Shows.Add(existing);
await seed.SaveChangesAsync(CancellationToken.None);
}
var provider = ProviderWith(
new MetadataCandidate("98", "Gladiator", 2000, null, null, ShowKind.Single)
);
await using var db = fixture.New();
var matcher = new MovieMatcher(db, Resolver(provider));
var rows = await matcher.MatchAsync(
["Gladiator.2000.BDRip"],
"tmdb",
CancellationToken.None
);
var row = Assert.Single(rows);
Assert.Equal(MovieMatchStatus.AlreadyInLibrary, row.Status);
Assert.Equal(existing.Id, row.ExistingShowId);
Assert.Equal("Гладиатор", row.ExistingShowName);
}
[Fact]
public async Task MatchQuery_UsesTitleTypedByHand()
{
// Транслит правится руками: имя файла остаётся ключом строки, а ищется вписанное название.
var fixture = new TestDb();
var provider = ProviderWith(
new MetadataCandidate("5", "Шоу Трумана", 1998, null, null, ShowKind.Single)
);
await using var db = fixture.New();
var matcher = new MovieMatcher(db, Resolver(provider));
var row = await matcher.MatchQueryAsync(
"Shou.Trumana.1998.BDRip.AVC.mkv",
"Шоу Трумана",
1998,
"tmdb",
CancellationToken.None
);
Assert.Equal("Shou.Trumana.1998.BDRip.AVC.mkv", row.Name);
Assert.Equal(MovieMatchStatus.Confident, row.Status);
Assert.Equal("5", row.PickedExternalId);
}
[Fact]
public async Task PickConfident_ReturnsNothing_WhenMatchIsLoose()
{
// Автоматика inbox обязана молчать там, где таблица разбора показала бы жёлтым.
var fixture = new TestDb();
var provider = ProviderWith(
new MetadataCandidate("3", "Совсем другое кино", 1999, null, null, ShowKind.Single)
);
await using var db = fixture.New();
var matcher = new MovieMatcher(db, Resolver(provider));
var picked = await matcher.PickConfidentAsync(
"Green.Mile.1999.bdrip.mkv",
"tmdb",
CancellationToken.None
);
Assert.Null(picked);
}
}
@@ -0,0 +1,105 @@
using TeleWave.Application.Media.MovieImport;
using Xunit;
namespace TeleWave.Application.Tests.Media;
/// <summary>
/// Разбор имён релизов. Набор — реальные имена из библиотеки пользователя: парсер, проверенный
/// на выдуманных примерах, разбирает выдуманные имена, а не те, что лежат в каталоге.
/// </summary>
public class MovieNameParserTests
{
[Theory]
// Год в скобках вместе с качеством — самый частый вид.
[InlineData("Die Hard 2 [1990 HDRip].avi", "Die Hard 2", 1990)]
[InlineData("Face Off [1997 BDRip].avi", "Face Off", 1997)]
[InlineData("Terminator (1984) Scan 35mm.mkv", "Terminator", 1984)]
// Точки вместо пробелов — вид с трекеров.
[InlineData("Voron.1994.TRIPLE.BDRip.XviD.AC3.avi", "Voron", 1994)]
[InlineData("Green.Mile.1999.bdrip-avc.dexter.mkv", "Green Mile", 1999)]
[InlineData("Gladiator.2000.Extended.DUB.Hybrid.mkv", "Gladiator", 2000)]
[InlineData("Bolshoi.Lebovski.1998.DUAL.BDRip", "Bolshoi Lebovski", 1998)]
[InlineData("Osnovnoi.Instinkt.1992.TRIPLE.BDRip.mkv", "Osnovnoi Instinkt", 1992)]
[InlineData("Shou.Trumana.1998.BDRip.AVC.mkv", "Shou Trumana", 1998)]
[InlineData("Pyatyi.Element.1997.BDRip.avi", "Pyatyi Element", 1997)]
[InlineData("Molchanie.yagnyat.1991.BDRip.avi", "Molchanie yagnyat", 1991)]
[InlineData("Liudi.V.Chernom.II.2002.BDRip-AVC.mkv", "Liudi V Chernom II", 2002)]
[InlineData("Double Trouble.1992.TRIPLERUS.BDRip.avi", "Double Trouble", 1992)]
// Подчёркивания и скобки с пометкой издания.
[InlineData("Forrest_Gump_1994_BDRip-AVC_b.mkv", "Forrest Gump", 1994)]
[InlineData("Unbreakable_2000_BDRip_[REMASTERED]", "Unbreakable", 2000)]
// Апостроф в названии переживает разбор.
[InlineData("Knockin' On Heaven's Door.1997.BDRip.mkv", "Knockin' On Heaven's Door", 1997)]
public void Parse_TakesTitleAndYear(string name, string title, int year)
{
var parsed = MovieNameParser.Parse(name);
Assert.Equal(title, parsed.Title);
Assert.Equal(year, parsed.Year);
}
[Theory]
// Года нет — название обрывается на первом техническом маркере.
[InlineData("Pulp.Fiction.HDRip.Kubik.v.Kube.mkv", "Pulp Fiction")]
[InlineData("From.Dusk.Till.Dawn.HDRip.Kubik.mkv", "From Dusk Till Dawn")]
[InlineData("Die Hard.mkv", "Die Hard")]
[InlineData("Temple.of.Doom.mkv", "Temple of Doom")]
[InlineData("Lock.Stock.and.Two.Smoking.Barrels.mkv", "Lock Stock and Two Smoking Barrels")]
public void Parse_WithoutYear_CutsAtReleaseMarker(string name, string title)
{
var parsed = MovieNameParser.Parse(name);
Assert.Equal(title, parsed.Title);
Assert.Null(parsed.Year);
}
[Fact]
public void Parse_DropsReleaserNickname_AndDoesNotTakeYearFromIt()
{
// Ник релизера идёт после «by», и год внутри него — не год выпуска.
var parsed = MovieNameParser.Parse("Edward Scissorhands by_IVAN@1990.mkv");
Assert.Equal("Edward Scissorhands", parsed.Title);
Assert.Null(parsed.Year);
}
[Fact]
public void Parse_KeepsNumericTitle()
{
// «2012» — это название, а не год: год берётся только со второго вхождения.
var parsed = MovieNameParser.Parse("2012.2009.BDRip.XviD.mkv");
Assert.Equal("2012", parsed.Title);
Assert.Equal(2009, parsed.Year);
}
[Fact]
public void Parse_KeepsFolderNameWhole_WhenTailLooksLikeExtension()
{
// У папки релиза «расширением» выглядит последняя часть имени, и отрезать её нельзя.
var parsed = MovieNameParser.Parse("Voron.1994.TRIPLE.BDRip.XviD.AC3");
Assert.Equal("Voron", parsed.Title);
Assert.Equal(1994, parsed.Year);
}
[Fact]
public void Parse_LeavesUnrecognizedNameAsIs()
{
// Ни года, ни маркеров: разбирать нечего, и подсовывать обрезок хуже, чем показать имя
// целиком — правит его всё равно человек.
var parsed = MovieNameParser.Parse("Back to the Future - Part II - More.mkv");
Assert.Equal("Back to the Future - Part II - More", parsed.Title);
Assert.Null(parsed.Year);
}
[Fact]
public void Parse_EmptyName_IsEmpty()
{
var parsed = MovieNameParser.Parse(" ");
Assert.Equal(string.Empty, parsed.Title);
Assert.Null(parsed.Year);
}
}