diff --git a/.env.example b/.env.example index 9d34be3..d6a7c8c 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,9 @@ Media__TranscodeThreads=3 Media__MaxParallelTranscodes=1 # Период опроса каталога inbox/ сканером, секунды. Media__InboxScanSeconds=15 +# Заводить фильм по файлу из inbox/, когда имя уверенно опознано в источнике метаданных. +# Неуверенное совпадение остаётся человеку: файл станет ассетом без шоу и попадёт в разбор. +Media__InboxAutoMovies=true # Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости. Media__NormalizeLoudness=true # Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить). diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index 55a5d59..e678c3a 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -8,6 +8,7 @@ using TeleWave.Application.Media; using TeleWave.Application.Media.Delete; using TeleWave.Application.Media.ListMedia; using TeleWave.Application.Media.ManualInbox; +using TeleWave.Application.Media.MovieImport; using TeleWave.Application.Media.Register; using TeleWave.Application.Media.Stats; using TeleWave.Domain.Media; @@ -33,6 +34,12 @@ public static class MediaEndpoints admin.MapGet("/manual", ListManual).Produces(); admin.MapPost("/manual/import", ImportManual).Produces(); + // Разбор фильмов: имена приходят с клиента (каталог manual/ либо выбранные в браузере + // файлы), сервер разбирает их и ищет в источнике. Импорт заводит шоу на файл. + admin.MapPost("/movies/match", MatchMovies).Produces>(); + admin.MapPost("/movies/match-one", MatchMovie).Produces(); + admin.MapPost("/movies/import", ImportMovies).Produces(); + // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer. admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); @@ -164,6 +171,45 @@ public static class MediaEndpoints return Results.Ok(result); } + private static async Task MatchMovies( + MatchMoviesBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new MatchMoviesQuery(body.Names, body.Provider), + cancellationToken + ); + return Results.Ok(result); + } + + private static async Task MatchMovie( + MatchMovieBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new MatchMovieQuery(body.Name, body.Title, body.Year, body.Provider), + cancellationToken + ); + return Results.Ok(result); + } + + private static async Task ImportMovies( + ImportMoviesBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ImportMoviesCommand(body.Items, body.Provider), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task ImportManual( ImportManualInboxBody body, ISender sender, @@ -228,3 +274,9 @@ public sealed record ListMediaFilter( public sealed record UploadMediaResponse(Guid Id); public sealed record ImportManualInboxBody(IReadOnlyList Items, Guid ShowId); + +public sealed record MatchMoviesBody(IReadOnlyList Names, string Provider); + +public sealed record MatchMovieBody(string Name, string Title, int? Year, string Provider); + +public sealed record ImportMoviesBody(IReadOnlyList Items, string Provider); diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index ef080bd..5807750 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -7,6 +7,7 @@ using TeleWave.Application.Broadcast.Bumpers; using TeleWave.Application.Common.Behaviors; using TeleWave.Application.Library.Collections.Suggest; using TeleWave.Application.Library.Genres; +using TeleWave.Application.Media.MovieImport; using TeleWave.Application.Metadata; using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Groups.Suggest; @@ -40,6 +41,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/TeleWave.Application/Media/MediaErrors.cs b/backend/src/TeleWave.Application/Media/MediaErrors.cs index c244389..89e39a9 100644 --- a/backend/src/TeleWave.Application/Media/MediaErrors.cs +++ b/backend/src/TeleWave.Application/Media/MediaErrors.cs @@ -43,4 +43,9 @@ public static class MediaErrors "Media.DuplicateFileName", "Файл с таким именем уже загружен." ); + + public static readonly Error AlreadyAttached = Error.Conflict( + "Media.AlreadyAttached", + "Файл уже привязан к шоу." + ); } diff --git a/backend/src/TeleWave.Application/Media/MovieImport/AutoAttachMovieCommand.cs b/backend/src/TeleWave.Application/Media/MovieImport/AutoAttachMovieCommand.cs new file mode 100644 index 0000000..c9f7756 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MovieImport/AutoAttachMovieCommand.cs @@ -0,0 +1,14 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Media.MovieImport; + +/// +/// Пытается завести фильм по только что подобранному из inbox/ файлу: разбирает имя, ищет +/// в источнике и, если совпадение уверенное, создаёт шоу с метаданными и привязывает ассет. +/// +/// Результат — «получилось или нет», а не ошибка: неуверенное совпадение это не сбой, а штатный +/// исход. Файл в таком случае остаётся ассетом без шоу и попадает в разбор наравне с тем, что +/// человек кладёт в manual/ руками. +/// +public sealed record AutoAttachMovieCommand(Guid AssetId, string FileName) : ICommand>; diff --git a/backend/src/TeleWave.Application/Media/MovieImport/AutoAttachMovieCommandHandler.cs b/backend/src/TeleWave.Application/Media/MovieImport/AutoAttachMovieCommandHandler.cs new file mode 100644 index 0000000..a4b53b3 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MovieImport/AutoAttachMovieCommandHandler.cs @@ -0,0 +1,73 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Metadata; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Media.MovieImport; + +public sealed class AutoAttachMovieCommandHandler( + IAppDbContext dbContext, + IMetadataProviderResolver resolver, + MovieMatcher matcher, + ShowMetadataApplier applier +) : ICommandHandler> +{ + public async Task> Handle( + AutoAttachMovieCommand command, + CancellationToken cancellationToken + ) + { + // Источник берём первый настроенный: выбирать его в фоне некому, а настроен обычно один. + if (resolver.AvailableKeys.FirstOrDefault() is not { } provider) + return Result.Success(false); + + var asset = await dbContext.MediaAssets.FirstOrDefaultAsync( + a => a.Id == command.AssetId, + cancellationToken + ); + if (asset is null) + return Result.Failure(MediaErrors.NotFound); + + // Ассет мог уже уехать в шоу вручную, пока файл ждал очереди. + var taken = await dbContext.Shows.AnyAsync( + s => s.Episodes.Any(e => e.MediaAssetId == command.AssetId), + cancellationToken + ); + if (taken) + return Result.Success(false); + + var candidate = await matcher.PickConfidentAsync( + command.FileName, + provider, + cancellationToken + ); + if (candidate is null) + return Result.Success(false); + + // Фильм, уже стоящий в библиотеке под этим идентификатором, повторно не заводим: это не + // второй фильм, а второй файл того же — разбирать такое человеку. + var duplicate = await dbContext.Shows.AnyAsync( + s => s.MetadataProvider == provider && s.MetadataExternalId == candidate.ExternalId, + cancellationToken + ); + if (duplicate) + return Result.Success(false); + + var show = Show.Create(candidate.Title, ShowKind.Single); + dbContext.Shows.Add(show); + show.AddEpisode(command.AssetId); + + var applied = await applier.ApplyAsync( + show, + provider, + candidate.ExternalId, + cancellationToken + ); + + // Метаданные не легли — шоу с файлом всё равно полезнее, чем ассет без шоу: название + // у него правильное, а постер дотянет массовое обогащение. + return Result.Success(applied.IsSuccess); + } +} diff --git a/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommand.cs b/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommand.cs new file mode 100644 index 0000000..c837d15 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommand.cs @@ -0,0 +1,57 @@ +using FluentValidation; +using LiteCqrs; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Media.MovieImport; + +/// +/// Один фильм к заведению. Источник файла ровно один из двух: путь в manual/ либо уже +/// загруженный браузером ассет — грузить файл заранее имеет смысл только после того, как строку +/// подтвердили. +/// +/// Название на случай, если кандидат не выбран: шоу всё равно надо как-то назвать. +/// Кандидат источника; null — завести шоу без метаданных. +public sealed record MovieImportItem( + string? RelativePath, + Guid? AssetId, + string Title, + int? Year, + string? ExternalId +); + +public sealed record ImportMoviesCommand(IReadOnlyList Items, string Provider) + : ICommand>; + +/// Сколько шоу заведено. +/// Скольким из них применены метаданные источника. +/// Что не получилось — по строке на фильм, а не одной ошибкой на всё. +public sealed record ImportMoviesResultDto( + int Created, + int Enriched, + IReadOnlyList Failed +); + +public sealed record MovieImportFailureDto(string Title, string Reason); + +public sealed class ImportMoviesCommandValidator : AbstractValidator +{ + public ImportMoviesCommandValidator() + { + RuleFor(x => x.Items).NotEmpty(); + RuleFor(x => x.Items.Count).LessThanOrEqualTo(200); + RuleFor(x => x.Provider).NotEmpty(); + + RuleForEach(x => x.Items) + .ChildRules(item => + { + item.RuleFor(i => i.Title).NotEmpty().MaximumLength(256); + item.RuleFor(i => i.Year) + .InclusiveBetween(1870, 2200) + .When(i => i.Year is not null); + // Ровно один источник файла: без файла шоу пустое, с двумя неясно, какой брать. + item.RuleFor(i => i) + .Must(i => string.IsNullOrWhiteSpace(i.RelativePath) != (i.AssetId is null)) + .WithMessage("У фильма должен быть ровно один источник файла."); + }); + } +} diff --git a/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs b/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs new file mode 100644 index 0000000..91b0019 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs @@ -0,0 +1,185 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Metadata; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; + +namespace TeleWave.Application.Media.MovieImport; + +/// +/// Заводит фильмы пачкой: шоу на файл, метаданные из источника, файл — в очередь обработки. +/// +/// Фильмы разбираются независимо: один неподдерживаемый контейнер или пропавший из manual/ +/// файл не должен отменять остальные сорок — в разборе чужой библиотеки это частый случай, и +/// заставлять человека повторять всю пачку из-за одной строки нельзя. +/// +public sealed class ImportMoviesCommandHandler( + IAppDbContext dbContext, + IMediaStorage storage, + IMediaProcessingQueue queue, + ShowMetadataApplier applier +) : ICommandHandler> +{ + public async Task> Handle( + ImportMoviesCommand command, + CancellationToken cancellationToken + ) + { + var available = storage + .ListManualInbox(int.MaxValue) + .ToDictionary(f => f.RelativePath, StringComparer.Ordinal); + + var failed = new List(); + var created = 0; + var enriched = 0; + + foreach (var item in command.Items) + { + var asset = await ResolveAssetAsync(item, available, failed, cancellationToken); + if (asset is null) + continue; + + var show = Show.Create(item.Title.Trim(), ShowKind.Single); + dbContext.Shows.Add(show); + show.AddEpisode(asset.Value.AssetId); + created++; + + if (asset.Value.Enqueue) + queue.Enqueue(asset.Value.AssetId); + + if (item.RelativePath is { Length: > 0 } path) + await storage.CleanupManualLeftoversAsync(path, cancellationToken); + + if (item.ExternalId is not { Length: > 0 } externalId) + continue; + + // Метаданные — отдельная неудача: шоу с файлом уже полезно, а без постера оно + // всего лишь неполно, и дотянуть его можно массовым обогащением. + var result = await applier.ApplyAsync( + show, + command.Provider, + externalId, + cancellationToken + ); + if (result.IsSuccess) + enriched++; + else + failed.Add(new MovieImportFailureDto(item.Title, result.Error.Message)); + } + + return Result.Success(new ImportMoviesResultDto(created, enriched, failed)); + } + + /// Ассет фильма: уже загруженный браузером либо забранный из manual/. + private async Task<(Guid AssetId, bool Enqueue)?> ResolveAssetAsync( + MovieImportItem item, + IReadOnlyDictionary available, + List failed, + CancellationToken cancellationToken + ) + { + if (item.AssetId is { } assetId) + { + var asset = await dbContext.MediaAssets.FirstOrDefaultAsync( + a => a.Id == assetId, + cancellationToken + ); + if (asset is null) + { + failed.Add(new MovieImportFailureDto(item.Title, MediaErrors.NotFound.Message)); + return null; + } + + // Загруженный ассет уже в очереди с момента загрузки — второй раз не ставим. + var taken = await dbContext.Shows.AnyAsync( + s => s.Episodes.Any(e => e.MediaAssetId == assetId), + cancellationToken + ); + if (taken) + { + failed.Add( + new MovieImportFailureDto(item.Title, MediaErrors.AlreadyAttached.Message) + ); + return null; + } + + return (assetId, false); + } + + if ( + item.RelativePath is not { Length: > 0 } path + || !available.TryGetValue(path, out var file) + ) + { + failed.Add(new MovieImportFailureDto(item.Title, MediaErrors.SourceNotFound.Message)); + return null; + } + + if (!MediaFormats.IsAllowed(file.Name)) + { + failed.Add( + new MovieImportFailureDto(item.Title, MediaErrors.UnsupportedFormat.Message) + ); + return null; + } + + var duplicate = await dbContext.MediaAssets.AnyAsync( + a => a.OriginalFileName == file.Name && a.Status != MediaAssetStatus.Failed, + cancellationToken + ); + if (duplicate) + { + failed.Add( + new MovieImportFailureDto(item.Title, MediaErrors.DuplicateFileName.Message) + ); + return null; + } + + var registered = await RegisterAsync(file, path, cancellationToken); + if (registered is null) + { + failed.Add(new MovieImportFailureDto(item.Title, MediaErrors.SourceNotFound.Message)); + return null; + } + + return (registered.Value, true); + } + + /// + /// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при ручном разборе: + /// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы + /// не осталось записи без исходника. + /// + private async Task RegisterAsync( + IMediaStorage.ManualInboxFile file, + string relativePath, + CancellationToken cancellationToken + ) + { + var extension = Path.GetExtension(file.Name).ToLowerInvariant(); + var asset = MediaAsset.Register(file.Name, extension, MediaSource.ManualInbox); + + dbContext.MediaAssets.Add(asset); + await dbContext.SaveChangesAsync(cancellationToken); + try + { + await storage.PromoteToOriginalAsync( + MediaSource.ManualInbox, + relativePath, + asset.Id, + extension, + cancellationToken + ); + } + catch (IOException) + { + dbContext.MediaAssets.Remove(asset); + await dbContext.SaveChangesAsync(cancellationToken); + return null; + } + + return asset.Id; + } +} diff --git a/backend/src/TeleWave.Application/Media/MovieImport/MatchMoviesQuery.cs b/backend/src/TeleWave.Application/Media/MovieImport/MatchMoviesQuery.cs new file mode 100644 index 0000000..d92e14f --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MovieImport/MatchMoviesQuery.cs @@ -0,0 +1,40 @@ +using FluentValidation; +using LiteCqrs; + +namespace TeleWave.Application.Media.MovieImport; + +/// +/// Разбор пачки имён: что это за фильмы и что по ним нашлось в источнике. Имена приходят с клиента, +/// а не читаются сервером, потому что источников два — каталог manual/ и файлы, выбранные +/// в браузере. Разбирать имя одинаково, а грузить файл только после того, как человек согласился +/// со строкой, — единственный способ не заливать десятки гигабайт впустую. +/// +public sealed record MatchMoviesQuery(IReadOnlyList Names, string Provider) + : IQuery>; + +/// Перезапрос одной строки: админ поправил название (транслит, другое написание). +public sealed record MatchMovieQuery(string Name, string Title, int? Year, string Provider) + : IQuery; + +public sealed class MatchMoviesQueryValidator : AbstractValidator +{ + public MatchMoviesQueryValidator() + { + RuleFor(x => x.Names).NotEmpty(); + // Потолок на пачку: каждая строка — запрос во внешний источник, и тысяча имён за раз + // упрётся в его лимиты, а не в наши. + RuleFor(x => x.Names.Count).LessThanOrEqualTo(200); + RuleFor(x => x.Provider).NotEmpty(); + } +} + +public sealed class MatchMovieQueryValidator : AbstractValidator +{ + public MatchMovieQueryValidator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.Title).NotEmpty().MaximumLength(256); + RuleFor(x => x.Year).InclusiveBetween(1870, 2200).When(x => x.Year is not null); + RuleFor(x => x.Provider).NotEmpty(); + } +} diff --git a/backend/src/TeleWave.Application/Media/MovieImport/MatchMoviesQueryHandler.cs b/backend/src/TeleWave.Application/Media/MovieImport/MatchMoviesQueryHandler.cs new file mode 100644 index 0000000..d64f4f9 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MovieImport/MatchMoviesQueryHandler.cs @@ -0,0 +1,25 @@ +using LiteCqrs; + +namespace TeleWave.Application.Media.MovieImport; + +public sealed class MatchMoviesQueryHandler(MovieMatcher matcher) + : IQueryHandler> +{ + public Task> Handle( + MatchMoviesQuery query, + CancellationToken cancellationToken + ) => matcher.MatchAsync(query.Names, query.Provider, cancellationToken); +} + +public sealed class MatchMovieQueryHandler(MovieMatcher matcher) + : IQueryHandler +{ + public Task Handle(MatchMovieQuery query, CancellationToken cancellationToken) => + matcher.MatchQueryAsync( + query.Name, + query.Title, + query.Year, + query.Provider, + cancellationToken + ); +} diff --git a/backend/src/TeleWave.Application/Media/MovieImport/MovieMatcher.cs b/backend/src/TeleWave.Application/Media/MovieImport/MovieMatcher.cs new file mode 100644 index 0000000..0651c57 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MovieImport/MovieMatcher.cs @@ -0,0 +1,227 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Metadata; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Media.MovieImport; + +/// Что стало с именем после разбора и поиска. +public enum MovieMatchStatus +{ + /// Название и год сошлись — можно импортировать не глядя. + Confident, + + /// Кандидаты есть, но точного совпадения нет: выбирать человеку. + Uncertain, + + /// Источник ничего не вернул — нужно вписать название руками. + NotFound, + + /// Такой фильм в библиотеке уже есть — по идентификатору источника. + AlreadyInLibrary, +} + +/// Одна строка разбора: что распозналось, что нашлось и что с этим делать. +public sealed record MovieMatchDto( + /// Имя, как его прислал клиент, — по нему строка и опознаётся при импорте. + string Name, + string ParsedTitle, + int? ParsedYear, + MovieMatchStatus Status, + IReadOnlyList Candidates, + /// Кандидат, выбранный автоматически (только при уверенном совпадении). + string? PickedExternalId, + /// Шоу, которое уже занимает этого кандидата. + Guid? ExistingShowId, + string? ExistingShowName +); + +/// +/// Подбор фильма по имени файла: разбор имени → поиск в источнике → решение, уверенное ли это +/// совпадение. Один и тот же код обслуживает и кнопку разбора, и автоматику inbox: правило +/// «что считать уверенным» обязано быть общим, иначе автоматика заведёт то, что таблица разбора +/// показала бы жёлтым. +/// +/// Уверенным считается совпадение, где нормализованное название сошлось дословно, год сошёлся +/// с точностью до года (у релизов год проката и год производства разъезжаются) и такой кандидат +/// ровно один. Всё остальное — человеку: цена ошибки здесь чужой постер и чужой рейтинг у фильма +/// в эфире. +/// +public sealed class MovieMatcher(IAppDbContext dbContext, IMetadataProviderResolver resolver) +{ + /// Сколько кандидатов показывать в строке: дальше третьего никто не смотрит. + private const int MaxCandidates = 5; + + /// Допуск по году. Больше единицы брать нельзя: сиквелы выходят с шагом в год. + private const int YearTolerance = 1; + + /// Шоу, уже занявшее кандидата источника. + private sealed record TakenShow(Guid Id, string Name); + + /// Разбор пачки имён: как их прислал клиент, так и вернутся — строка к строке. + public async Task> MatchAsync( + IReadOnlyList names, + string providerKey, + CancellationToken cancellationToken + ) + { + if (resolver.Resolve(providerKey) is not { } provider) + return []; + + var taken = await TakenAsync(providerKey, cancellationToken); + var rows = new List(names.Count); + + foreach (var name in names.Distinct(StringComparer.Ordinal)) + { + var parsed = MovieNameParser.Parse(name); + rows.Add(await MatchOneAsync(name, parsed, provider, taken, cancellationToken)); + } + + return rows; + } + + /// Подбор по названию, которое админ вписал руками вместо распознанного. + public async Task MatchQueryAsync( + string name, + string title, + int? year, + string providerKey, + CancellationToken cancellationToken + ) + { + if (resolver.Resolve(providerKey) is not { } provider) + return NothingFound(name, title, year); + + var taken = await TakenAsync(providerKey, cancellationToken); + return await MatchOneAsync( + name, + new ParsedMovieName(title, year), + provider, + taken, + cancellationToken + ); + } + + /// + /// Уверенный кандидат для автоматики inbox. Возвращает null всегда, когда решение неочевидно, — + /// молчаливый пропуск здесь лучше молчаливой ошибки: файл останется ассетом без шоу и попадёт + /// в тот же разбор, что и всё непонятное. + /// + public async Task PickConfidentAsync( + string fileName, + string providerKey, + CancellationToken cancellationToken + ) + { + if (resolver.Resolve(providerKey) is not { } provider) + return null; + + var parsed = MovieNameParser.Parse(fileName); + if (string.IsNullOrWhiteSpace(parsed.Title)) + return null; + + var candidates = await provider.SearchShowsAsync( + parsed.Title, + ShowKind.Single, + cancellationToken + ); + + return Pick([.. candidates.Take(MaxCandidates)], parsed); + } + + private async Task> TakenAsync( + string providerKey, + CancellationToken cancellationToken + ) + { + // Один запрос на весь разбор, а не запрос на строку: имён в пачке сотни. + var shows = await dbContext + .Shows.AsNoTracking() + .Where(s => s.MetadataProvider == providerKey && s.MetadataExternalId != null) + .Select(s => new + { + s.Id, + s.Name, + ExternalId = s.MetadataExternalId!, + }) + .ToListAsync(cancellationToken); + + var taken = new Dictionary(StringComparer.Ordinal); + foreach (var show in shows) + taken.TryAdd(show.ExternalId, new TakenShow(show.Id, show.Name)); + + return taken; + } + + private static async Task MatchOneAsync( + string name, + ParsedMovieName parsed, + IMetadataProvider provider, + IReadOnlyDictionary taken, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(parsed.Title)) + return NothingFound(name, parsed.Title, parsed.Year); + + var found = await provider.SearchShowsAsync( + parsed.Title, + ShowKind.Single, + cancellationToken + ); + var candidates = found.Take(MaxCandidates).ToList(); + if (candidates.Count == 0) + return NothingFound(name, parsed.Title, parsed.Year); + + var picked = Pick(candidates, parsed); + var existing = picked is null ? null : taken.GetValueOrDefault(picked.ExternalId); + + var status = + existing is not null ? MovieMatchStatus.AlreadyInLibrary + : picked is not null ? MovieMatchStatus.Confident + : MovieMatchStatus.Uncertain; + + return new MovieMatchDto( + name, + parsed.Title, + parsed.Year, + status, + candidates, + picked?.ExternalId, + existing?.Id, + existing?.Name + ); + } + + private static MovieMatchDto NothingFound(string name, string title, int? year) => + new(name, title, year, MovieMatchStatus.NotFound, [], null, null, null); + + /// + /// Единственный кандидат, у которого название совпало дословно, а год сошёлся. Несколько таких — + /// значит выбирать не из чего: два «Мстителя» одного года различит только человек. + /// + private static MetadataCandidate? Pick( + IReadOnlyList candidates, + ParsedMovieName parsed + ) + { + var matches = candidates + .Where(c => Same(c.Title, parsed.Title) && YearFits(c.Year, parsed.Year)) + .ToList(); + + return matches.Count == 1 ? matches[0] : null; + } + + private static bool Same(string left, string right) => + Normalize(left).Length > 0 && Normalize(left) == Normalize(right); + + /// Сравниваем по буквам и цифрам: «Spider-Man» и «Spider Man» — одно название. + private static string Normalize(string value) => + string.Concat(value.Where(char.IsLetterOrDigit)).ToLowerInvariant(); + + /// Год без года не проверяется: имя без года — обычное дело, и отбрасывать его нельзя. + private static bool YearFits(int? candidate, int? parsed) => + parsed is not { } year + || candidate is not { } found + || Math.Abs(found - year) <= YearTolerance; +} diff --git a/backend/src/TeleWave.Application/Media/MovieImport/MovieNameParser.cs b/backend/src/TeleWave.Application/Media/MovieImport/MovieNameParser.cs new file mode 100644 index 0000000..afe6d54 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/MovieImport/MovieNameParser.cs @@ -0,0 +1,194 @@ +using System.Text.RegularExpressions; + +namespace TeleWave.Application.Media.MovieImport; + +/// Что удалось вытащить из имени файла или папки релиза. +/// Название для поиска в источнике; пустым не бывает. +/// Год, если он в имени был. Главный признак совпадения при подборе. +public sealed record ParsedMovieName(string Title, int? Year); + +/// +/// Разбирает имя релиза на название и год: «Voron.1994.TRIPLE.BDRip.XviD.AC3» → «Voron» + 1994. +/// +/// Порядок разбора не случаен. Сначала отрезается хвост релиза по первому же техническому маркеру +/// (rip, кодек, разрешение, «by_...»), и только потом в остатке ищется год: иначе «Edward +/// Scissorhands by_IVAN@1990» дало бы название «Edward Scissorhands by IVAN@», а год в никнейме +/// релизера сошёл бы за год выпуска. +/// +/// Разбор заведомо неточен — имена пишут люди и роботы десятка трекеров, — поэтому он и не обязан +/// быть точным: его результат попадает в таблицу, где строку правят руками. Задача парсера — чтобы +/// правил человек единицы строк, а не все. +/// +public static partial class MovieNameParser +{ + /// + /// Технические маркеры релиза: с первого из них начинается хвост, к названию не относящийся. + /// Сравниваются целыми словами, поэтому «Triple» из «TRIPLERUS» сюда не попадёт — и правильно, + /// такие склейки к названию тоже не относятся, но резать по ним рискованно. + /// + private static readonly IReadOnlySet Markers = new HashSet( + StringComparer.OrdinalIgnoreCase + ) + { + // Источник картинки. «web» и «rip» сами по себе не маркеры: «Charlotte's Web» — название, + // и обрезать по такому слову дороже, чем оставить лишний хвост. + "bdrip", + "brrip", + "bdremux", + "remux", + "bluray", + "bdrip-avc", + "dvdrip", + "dvd5", + "dvd9", + "hdrip", + "hdtv", + "webrip", + "web-dl", + "webdl", + "tvrip", + "satrip", + "camrip", + "telesync", + "scan", + // Кодеки и звук + "xvid", + "divx", + "x264", + "x265", + "h264", + "h265", + "avc", + "hevc", + "ac3", + "aac", + "dts", + "mp3", + "flac", + "opus", + // Разрешение и мастеринг + "480p", + "720p", + "1080p", + "1080i", + "2160p", + "4k", + "uhd", + "hdr", + "sdr", + "10bit", + // Издание и озвучка + "remastered", + "extended", + "theatrical", + "unrated", + "directors", + "proper", + "repack", + "dual", + "triple", + "dub", + "dubbed", + "subbed", + "hybrid", + "licence", + // Кто собрал + "by", + }; + + /// Символы, которые остаются висеть на краях названия после отрезания хвоста. + private static readonly char[] Edges = + [ + '-', + '–', + '—', + '_', + '.', + ',', + ':', + ';', + '(', + '[', + '{', + ')', + ']', + '}', + '\'', + ]; + + [GeneratedRegex(@"(? + /// Разбирает имя файла или папки. Расширение отрезается только знакомое: у папки + /// «Voron.1994.TRIPLE.BDRip.XviD.AC3» иначе «расширением» стало бы «.AC3», а с ним ушёл бы + /// кусок имени. + /// + public static ParsedMovieName Parse(string fileOrFolderName) + { + if (string.IsNullOrWhiteSpace(fileOrFolderName)) + return new ParsedMovieName(string.Empty, null); + + var text = MediaFormats.IsAllowed(fileOrFolderName) + ? Path.GetFileNameWithoutExtension(fileOrFolderName) + : fileOrFolderName; + + // Разделители релизов — точка и подчёркивание; дефис оставляем, он часто внутри названия + // («Blade Runner 2049 - Final Cut»), а хвост всё равно отрежется по маркеру. + var head = Head(text.Replace('.', ' ').Replace('_', ' ')); + + var year = Year(head, out var beforeYear); + var title = Clean(year is null ? head : beforeYear); + + // Название из одного года — это и есть название («2012», «1917»): в таком имени резать нечего. + return new ParsedMovieName(title.Length > 0 ? title : Clean(head), year); + } + + /// Часть имени до первого технического маркера. + private static string Head(string text) + { + var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var taken = new List(words.Length); + + foreach (var word in words) + { + var bare = word.Trim(Edges); + // Первое слово маркером не считаем: названия «Extended Family» и «Dual» существуют, + // а название из нуля слов — нет. + var marker = taken.Count > 0 && Markers.Contains(bare); + // Ник релизера — «by_IVAN@1990», «[Kubik@rutracker]»: собачка в слове бывает только там. + if (marker || bare.Contains('@', StringComparison.Ordinal)) + break; + taken.Add(word); + } + + // Имя целиком из маркеров разобрать нечем — пусть человек видит его как есть. + return taken.Count > 0 ? string.Join(' ', taken) : text; + } + + /// + /// Год выпуска и всё, что стоит до него. Год в самом начале не в счёт: «2012 2009 BDRip» — + /// это фильм «2012» девятого года, а не наоборот. + /// + private static int? Year(string text, out string beforeYear) + { + beforeYear = text; + + foreach (Match match in YearPattern().Matches(text)) + { + if (match.Index == 0) + continue; + + beforeYear = text[..match.Index]; + return int.Parse(match.Value, System.Globalization.CultureInfo.InvariantCulture); + } + + return null; + } + + private static string Clean(string title) => + ExtraSpaces().Replace(title, " ").Trim().Trim(Edges).Trim(); +} diff --git a/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs b/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs index 511700c..80fd33d 100644 --- a/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs +++ b/backend/src/TeleWave.Infrastructure/Media/InboxScannerBackgroundService.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Media; +using TeleWave.Application.Media.MovieImport; using TeleWave.Application.Media.Register; using TeleWave.Domain.Media; @@ -128,6 +129,7 @@ public sealed class InboxScannerBackgroundService( result.Value, fileName ); + await AttachMovieAsync(sender, result.Value, fileName, cancellationToken); return true; } @@ -138,4 +140,35 @@ public sealed class InboxScannerBackgroundService( ); return false; } + + /// + /// Пробует завести фильм по имени файла. Неуверенное совпадение — не ошибка: ассет останется + /// без шоу и попадёт в разбор. Сбой источника метаданных тоже не должен ронять сканер — файл + /// уже зарегистрирован, и это главное. + /// + private async Task AttachMovieAsync( + ISender sender, + Guid assetId, + string fileName, + CancellationToken cancellationToken + ) + { + if (!_media.InboxAutoMovies) + return; + + try + { + var attached = await sender.Send( + new AutoAttachMovieCommand(assetId, fileName), + cancellationToken + ); + + if (attached is { IsSuccess: true, Value: true }) + logger.LogInformation("По файлу {File} заведён фильм из inbox", fileName); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Не удалось опознать фильм по имени {File}", fileName); + } + } } diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs b/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs index 9ddc38e..e888106 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs @@ -22,6 +22,14 @@ public sealed class MediaOptions /// Период опроса inbox/ сканером, секунды. public int InboxScanSeconds { get; init; } = 15; + /// + /// Заводить фильм по файлу из inbox/ автоматически, когда имя уверенно опознано в источнике + /// метаданных. Неуверенное совпадение всё равно остаётся человеку — файл станет ассетом без шоу + /// и попадёт в разбор. Выключить стоит, если inbox наполняется сериалами: у них имя опознаётся + /// как фильм ровно никогда, и лишние запросы к источнику ни к чему. + /// + public bool InboxAutoMovies { get; init; } = true; + /// Нормализовать громкость при обработке (ffmpeg loudnorm, EBU R128) — чтобы все ролики /// звучали одинаково. public bool NormalizeLoudness { get; init; } = true; diff --git a/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs b/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs new file mode 100644 index 0000000..1bae1f9 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs @@ -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; + +/// +/// Массовое заведение фильмов: шоу на файл, метаданные из источника, файл в очередь. Фильмы +/// разбираются независимо — одна битая строка не должна отменять остальные сорок. +/// +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) + ); + + 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(); + + 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 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) + ); + 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) + ); + + 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)); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Media/MovieMatcherTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MovieMatcherTests.cs new file mode 100644 index 0000000..85053f3 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/MovieMatcherTests.cs @@ -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; + +/// +/// Подбор фильма по имени файла. Главное свойство то же, что у массового обогащения: уверенным +/// считается только дословное совпадение — чужой постер у фильма в эфире хуже, чем его отсутствие. +/// +public class MovieMatcherTests +{ + private static IMetadataProvider ProviderWith(params MetadataCandidate[] candidates) + { + var provider = Substitute.For(); + provider + .SearchShowsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(candidates); + return provider; + } + + private static IMetadataProviderResolver Resolver(IMetadataProvider provider) + { + var resolver = Substitute.For(); + 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); + } +} diff --git a/backend/tests/TeleWave.Application.Tests/Media/MovieNameParserTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MovieNameParserTests.cs new file mode 100644 index 0000000..1eeb3a0 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/MovieNameParserTests.cs @@ -0,0 +1,105 @@ +using TeleWave.Application.Media.MovieImport; +using Xunit; + +namespace TeleWave.Application.Tests.Media; + +/// +/// Разбор имён релизов. Набор — реальные имена из библиотеки пользователя: парсер, проверенный +/// на выдуманных примерах, разбирает выдуманные имена, а не те, что лежат в каталоге. +/// +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); + } +} diff --git a/docs/media-storage-and-streaming.md b/docs/media-storage-and-streaming.md index 8f26caf..3c046e6 100644 --- a/docs/media-storage-and-streaming.md +++ b/docs/media-storage-and-streaming.md @@ -203,6 +203,45 @@ Inbox-сканер — второй `BackgroundService`: замечает нов `MediaAsset` в `Pending` и ставит в очередь. Из inbox файлы попадают в библиотеку как отдельные ассеты; привязка к шоу/сериям — уже действие админа в UI. +## Разбор фильмов + +Сериал грузится пачкой файлов **в одно шоу** — этим занят ручной разбор `manual/`. У полнометражек +наоборот: файл — это отдельное шоу, и вручную заводить сорок штук никто не станет. Отсюда отдельный +разбор, у которого три входа и один общий код. + +**Разбор имени** (`MovieNameParser`) даёт название и год. Порядок: сначала отрезается хвост релиза +по первому техническому маркеру (rip, кодек, разрешение, `by_...`), и только в остатке ищется год — +иначе «Edward Scissorhands by_IVAN@1990» дал бы название с ником релизера, а год из ника сошёл бы +за год выпуска. Год в начале имени годом не считается: «2012.2009.BDRip» — это фильм «2012» девятого +года. Первое слово маркером не считается («Extended Family» существует), а `web` и `rip` из списка +маркеров исключены — «Charlotte's Web» дороже лишнего хвоста. Папка релиза разбирается по имени +папки: оно чище, чем `video.mkv` внутри, а файлом берётся самый большой (остальное — сэмплы). + +**Подбор** (`MovieMatcher`) ищет по названию среди полнометражек источника и решает, уверенное ли +это совпадение: нормализованное название сошлось дословно, год сошёлся с точностью до года (год +проката и год производства расходятся) и такой кандидат ровно один. Правило то же, что у массового +обогащения из 6.x, и по той же причине: чужой постер и чужой рейтинг у фильма в эфире хуже, чем их +отсутствие. Статусы строки — «совпадение точное», «проверьте», «не найдено», «уже в библиотеке» +(по идентификатору источника). + +**Три входа, одно правило:** + +- **Кнопка, файлы из `manual/`.** Таблица разбора: строку правят руками (транслитные имена вроде + `Shou.Trumana` источник не находит — впишите название и перезапросите одну строку), затем импорт + заводит шоу, применяет метаданные и отправляет файл в очередь. +- **Кнопка, файлы с диска.** Имена разбираются **до** загрузки: узнать, что половина строк не + распозналась, после заливки десятков гигабайт — слишком поздно. Подтверждённые файлы грузятся + по одному, и каждый заводится сразу после своего аплоада. +- **Автоматика inbox.** Сканер после регистрации ассета пробует опознать фильм и заводит шоу + **только при уверенном совпадении** (`Media__InboxAutoMovies`, по умолчанию включено). Всё + неуверенное молча остаётся ассетом без шоу и попадает в ту же таблицу разбора — автоматика + обязана молчать там, где таблица показала бы жёлтым. + +Шоу импорт создаёт, а **шоу-контент — никогда**: файл приносит загрузка, и придуманное источником +название не должно превращаться в запись библиотеки без файла. Франшизы отдельным шагом не +собираются: `FranchiseExternalId` проставляется вместе с метаданными, а коллекции предлагает +существующий `CollectionSuggestionBuilder` — там виден состав, и спин-оффы TMDb отсекает человек. + ## Слои и API **Application** — порты `IMediaStorage` (резолв путей, запись, удаление, свободное место), @@ -231,6 +270,7 @@ Storage__StaleUploadHours=24 Media__FfmpegPath=/usr/bin/ffmpeg Media__FfprobePath=/usr/bin/ffprobe Media__MaxUploadBytes=21474836480 +Media__InboxAutoMovies=true Scheduler__HorizonDays=7 Scheduler__RetentionDays=90 Scheduler__TickMinutes=30 diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 91995a3..4b7b950 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { FolderInput, ListPlus, Upload } from 'lucide-react' +import { Clapperboard, FolderInput, ListPlus, Upload } from 'lucide-react' import { qk } from '@/shared/api/query-keys' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import { useApiError } from '@/shared/lib/use-api-error' @@ -15,6 +15,7 @@ import { SortHeader } from '@/shared/ui/sortable' import { deleteMedia, getMediaStats, listMedia } from './api' import { formatDuration, splitEta } from './format' import { ManualInboxDialog } from './ManualInboxDialog' +import { MovieImportDialog } from './MovieImportDialog' import { UploadToShowDialog } from './UploadToShowDialog' import { useUploadStore } from './upload-store' @@ -48,6 +49,7 @@ export function MediaPanel() { const { sort, toggle } = useTableSort('created', true) const [filesForShow, setFilesForShow] = useState(null) const [manualOpen, setManualOpen] = useState(false) + const [moviesOpen, setMoviesOpen] = useState(false) const enqueue = useUploadStore((s) => s.enqueue) const sortColumn = (key: string) => { @@ -182,6 +184,10 @@ export function MediaPanel() { e.target.value = '' }} /> + + + + + + ) +} diff --git a/frontend/src/features/admin/media/api.ts b/frontend/src/features/admin/media/api.ts index e1fbb1a..4c015d5 100644 --- a/frontend/src/features/admin/media/api.ts +++ b/frontend/src/features/admin/media/api.ts @@ -2,6 +2,9 @@ import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client' import type { CreatedIdResponse, ImportManualInboxResultDto, + ImportMoviesResultDto, + MovieImportItem, + MovieMatchDto, ManualImportItem, ManualInboxListDto, MediaAssetDto, @@ -128,3 +131,33 @@ export function uploadMedia( xhr.send(file) }) } + +// ── Разбор фильмов ──────────────────────────────────────────────────────── + +/** + * Разбирает имена и ищет кандидатов в источнике. Имена шлём с клиента, потому что источника два: + * каталог `manual/` и файлы, выбранные в браузере, — а разбирать их надо одинаково и **до** того, + * как что-то поедет на сервер. + */ +export function matchMovies(names: string[], provider: string) { + return apiRequest('/admin/media/movies/match', { + method: 'POST', + body: { names, provider }, + }) +} + +/** Перезапрос одной строки: админ поправил название (транслит, другое написание). */ +export function matchMovie(name: string, title: string, year: number | null, provider: string) { + return apiRequest('/admin/media/movies/match-one', { + method: 'POST', + body: { name, title, year, provider }, + }) +} + +/** Заводит фильмы: шоу на файл, метаданные по выбранному кандидату, файл — в очередь обработки. */ +export function importMovies(items: MovieImportItem[], provider: string) { + return apiRequest('/admin/media/movies/import', { + method: 'POST', + body: { items, provider }, + }) +} diff --git a/frontend/src/features/admin/media/upload-store.ts b/frontend/src/features/admin/media/upload-store.ts index dbf1f37..67e3811 100644 --- a/frontend/src/features/admin/media/upload-store.ts +++ b/frontend/src/features/admin/media/upload-store.ts @@ -6,7 +6,7 @@ import { importInterstitials } from '@/features/admin/interstitials/api' import { addEpisode } from '@/features/admin/shows/api' import { toast } from '@/shared/ui/toast-store' import type { CreatedIdResponse } from '@/shared/api/types' -import { listMedia, uploadMedia } from './api' +import { importMovies, listMedia, uploadMedia } from './api' export type UploadItem = { id: string @@ -38,11 +38,28 @@ type EnqueueOptions = { resolveShowId?: (file: File) => string | undefined /** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */ interstitial?: boolean + /** + * Разбор фильмов: по каждому файлу заводится своё шоу с выбранными в таблице названием и + * кандидатом источника. Возвращает undefined — файл грузится как обычно, без шоу. + */ + resolveMovie?: (file: File) => MoviePlan | undefined + /** Источник метаданных для заведения фильмов — тот, что выбран в таблице разбора. */ + provider?: string } +/** Что известно про фильм к моменту загрузки файла: строку таблицы уже подтвердили. */ +export type MoviePlan = { title: string; year: number | null; externalId: string | null } + // Очередь и флаг живут вне React — загрузка продолжается при любой навигации. let counter = 0 -type Job = { id: string; file: File; showId?: string; interstitial?: boolean } +type Job = { + id: string + file: File + showId?: string + interstitial?: boolean + movie?: MoviePlan + provider?: string +} const queue: Job[] = [] const controllers = new Map() const failed = new Map() // упавшие — для ручного повтора @@ -109,6 +126,26 @@ async function linkUploaded(job: Job, assetId: string) { } catch { toast.error(`${job.file.name}: не удалось добавить в шоу`) } + } else if (job.movie && job.provider) { + // Фильм заводится сразу после аплоада: строку уже подтвердили в таблице разбора, и ждать + // окончания всей пачки незачем — библиотека наполняется по мере загрузки. + try { + await importMovies( + [ + { + relativePath: null, + assetId, + title: job.movie.title, + year: job.movie.year, + externalId: job.movie.externalId, + }, + ], + job.provider, + ) + void queryClient.invalidateQueries({ queryKey: qk.shows.all }) + } catch { + toast.error(`${job.file.name}: не удалось завести фильм`) + } } else if (job.interstitial) { // Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается. try { @@ -180,7 +217,14 @@ export const useUploadStore = create((set) => ({ const newItems: UploadItem[] = toAdd.map((file) => { const id = `u${++counter}` const showId = options?.resolveShowId?.(file) ?? options?.showId - queue.push({ id, file, showId, interstitial: options?.interstitial }) + queue.push({ + id, + file, + showId, + interstitial: options?.interstitial, + movie: options?.resolveMovie?.(file), + provider: options?.provider, + }) return { id, name: file.name, percent: 0, status: 'queued' } }) diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 7e639c1..7d3b407 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -81,6 +81,44 @@ export type ManualImportItem = { episode: number | null } +/** Кандидат из источника метаданных — то же, что показывает поиск на карточке шоу. */ +export type MetadataCandidateDto = { + externalId: string + title: string + year: number | null + overview: string | null + posterUrl: string | null +} + +export type MovieMatchStatus = 'Confident' | 'Uncertain' | 'NotFound' | 'AlreadyInLibrary' + +/** Строка разбора фильмов: что распозналось в имени и что по нему нашлось. */ +export type MovieMatchDto = { + name: string + parsedTitle: string + parsedYear: number | null + status: MovieMatchStatus + candidates: MetadataCandidateDto[] + pickedExternalId: string | null + existingShowId: string | null + existingShowName: string | null +} + +/** Фильм к заведению: файл берётся из manual/ (relativePath) либо уже загружен (assetId). */ +export type MovieImportItem = { + relativePath: string | null + assetId: string | null + title: string + year: number | null + externalId: string | null +} + +export type ImportMoviesResultDto = { + created: number + enriched: number + failed: { title: string; reason: string }[] +} + export type ManualInboxListDto = { files: ManualInboxFileDto[] /** Выдача обрезана лимитом — в каталоге есть ещё. */ diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 333d21f..c3f9ea0 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -245,10 +245,39 @@ export const en = { create: 'Create', created: 'User created', }, + movies: { + title: 'Movie import', + hint: 'The file name is parsed into a title and a year, and the movie is looked up in the metadata source. Every file becomes its own show; fix the rows where the match is not exact.', + sources: { manual: 'From manual folder', disk: 'From my disk' }, + pickFiles: 'Pick files', + scan: 'Parse ({{count}})', + scanning: 'Searching the source…', + nothingYet: 'Pick a source and press "Parse".', + noProvider: 'No metadata source configured — there is nothing to search with.', + file: 'File', + parsed: 'Title and year', + candidate: 'Candidate', + year: 'Year', + research: 'Search again', + empty: 'nothing found', + noCandidate: 'no metadata', + import: 'Create ({{count}})', + imported: 'Movies created: {{created}}, with metadata: {{enriched}}', + uploadStarted: 'Files are uploading — movies appear as they finish', + collectionsHint: + 'Franchises turn into collections after the import — the suggestions wait on the Collections tab.', + statuses: { + Confident: 'exact match', + Uncertain: 'check this', + NotFound: 'not found — type the title', + AlreadyInLibrary: 'already in the library', + }, + }, media: { title: 'Media', upload: 'Upload', manualButton: 'From manual folder', + moviesButton: 'Movies', manualTitle: 'Manual pick from the manual folder', manualHint: 'Files in manual/ are not picked up by the scanner — select the ones you need and choose a show. Imported files leave the folder, just like from inbox.', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 2d964fe..98cd7d6 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -246,10 +246,39 @@ export const ru = { create: 'Создать', created: 'Пользователь создан', }, + movies: { + title: 'Разбор фильмов', + hint: 'Имя файла разбирается на название и год, по ним ищется фильм в источнике метаданных. Каждый файл становится отдельным шоу; строки, где совпадение неточное, поправьте руками.', + sources: { manual: 'Из папки manual', disk: 'С моего диска' }, + pickFiles: 'Выбрать файлы', + scan: 'Разобрать ({{count}})', + scanning: 'Ищем в источнике…', + nothingYet: 'Выберите источник и нажмите «Разобрать».', + noProvider: 'Источник метаданных не настроен — без него искать нечем.', + file: 'Файл', + parsed: 'Название и год', + candidate: 'Кандидат', + year: 'Год', + research: 'Искать заново', + empty: 'ничего не нашлось', + noCandidate: 'без метаданных', + import: 'Завести ({{count}})', + imported: 'Заведено фильмов: {{created}}, с метаданными: {{enriched}}', + uploadStarted: 'Файлы пошли на загрузку — фильмы заведутся по мере готовности', + collectionsHint: + 'Франшизы после импорта соберутся в коллекции — предложения ждут на вкладке «Коллекции».', + statuses: { + Confident: 'совпадение точное', + Uncertain: 'проверьте', + NotFound: 'не найдено — впишите название', + AlreadyInLibrary: 'уже в библиотеке', + }, + }, media: { title: 'Медиа', upload: 'Загрузить', manualButton: 'Из папки manual', + moviesButton: 'Фильмы', manualTitle: 'Ручной разбор папки manual', manualHint: 'Файлы из manual/ не разбираются сканером — выберите нужные и укажите шоу. Импортированные файлы уходят из папки, как и из inbox.',