Implement movie import functionality and enhance media processing
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:
@@ -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<ManualInboxListDto>();
|
||||
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
|
||||
|
||||
// Разбор фильмов: имена приходят с клиента (каталог manual/ либо выбранные в браузере
|
||||
// файлы), сервер разбирает их и ищет в источнике. Импорт заводит шоу на файл.
|
||||
admin.MapPost("/movies/match", MatchMovies).Produces<IReadOnlyList<MovieMatchDto>>();
|
||||
admin.MapPost("/movies/match-one", MatchMovie).Produces<MovieMatchDto>();
|
||||
admin.MapPost("/movies/import", ImportMovies).Produces<ImportMoviesResultDto>();
|
||||
|
||||
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
||||
// по 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<IResult> 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<IResult> 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<IResult> 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<IResult> ImportManual(
|
||||
ImportManualInboxBody body,
|
||||
ISender sender,
|
||||
@@ -228,3 +274,9 @@ public sealed record ListMediaFilter(
|
||||
public sealed record UploadMediaResponse(Guid Id);
|
||||
|
||||
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
|
||||
|
||||
public sealed record MatchMoviesBody(IReadOnlyList<string> Names, string Provider);
|
||||
|
||||
public sealed record MatchMovieBody(string Name, string Title, int? Year, string Provider);
|
||||
|
||||
public sealed record ImportMoviesBody(IReadOnlyList<MovieImportItem> Items, string Provider);
|
||||
|
||||
@@ -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<BumperSpecLoader>();
|
||||
services.AddScoped<GenreMatcher>();
|
||||
services.AddScoped<ShowMetadataApplier>();
|
||||
services.AddScoped<MovieMatcher>();
|
||||
services.AddScoped<CollectionSuggestionBuilder>();
|
||||
services.AddScoped<GroupElementResolver>();
|
||||
services.AddScoped<GroupFilterMatcher>();
|
||||
|
||||
@@ -43,4 +43,9 @@ public static class MediaErrors
|
||||
"Media.DuplicateFileName",
|
||||
"Файл с таким именем уже загружен."
|
||||
);
|
||||
|
||||
public static readonly Error AlreadyAttached = Error.Conflict(
|
||||
"Media.AlreadyAttached",
|
||||
"Файл уже привязан к шоу."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Media.MovieImport;
|
||||
|
||||
/// <summary>
|
||||
/// Пытается завести фильм по только что подобранному из <c>inbox/</c> файлу: разбирает имя, ищет
|
||||
/// в источнике и, если совпадение уверенное, создаёт шоу с метаданными и привязывает ассет.
|
||||
///
|
||||
/// Результат — «получилось или нет», а не ошибка: неуверенное совпадение это не сбой, а штатный
|
||||
/// исход. Файл в таком случае остаётся ассетом без шоу и попадает в разбор наравне с тем, что
|
||||
/// человек кладёт в <c>manual/</c> руками.
|
||||
/// </summary>
|
||||
public sealed record AutoAttachMovieCommand(Guid AssetId, string FileName) : ICommand<Result<bool>>;
|
||||
@@ -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<AutoAttachMovieCommand, Result<bool>>
|
||||
{
|
||||
public async Task<Result<bool>> 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<bool>(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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
using TeleWave.Application.Common.Models;
|
||||
|
||||
namespace TeleWave.Application.Media.MovieImport;
|
||||
|
||||
/// <summary>
|
||||
/// Один фильм к заведению. Источник файла ровно один из двух: путь в <c>manual/</c> либо уже
|
||||
/// загруженный браузером ассет — грузить файл заранее имеет смысл только после того, как строку
|
||||
/// подтвердили.
|
||||
/// </summary>
|
||||
/// <param name="Title">Название на случай, если кандидат не выбран: шоу всё равно надо как-то назвать.</param>
|
||||
/// <param name="ExternalId">Кандидат источника; null — завести шоу без метаданных.</param>
|
||||
public sealed record MovieImportItem(
|
||||
string? RelativePath,
|
||||
Guid? AssetId,
|
||||
string Title,
|
||||
int? Year,
|
||||
string? ExternalId
|
||||
);
|
||||
|
||||
public sealed record ImportMoviesCommand(IReadOnlyList<MovieImportItem> Items, string Provider)
|
||||
: ICommand<Result<ImportMoviesResultDto>>;
|
||||
|
||||
/// <param name="Created">Сколько шоу заведено.</param>
|
||||
/// <param name="Enriched">Скольким из них применены метаданные источника.</param>
|
||||
/// <param name="Failed">Что не получилось — по строке на фильм, а не одной ошибкой на всё.</param>
|
||||
public sealed record ImportMoviesResultDto(
|
||||
int Created,
|
||||
int Enriched,
|
||||
IReadOnlyList<MovieImportFailureDto> Failed
|
||||
);
|
||||
|
||||
public sealed record MovieImportFailureDto(string Title, string Reason);
|
||||
|
||||
public sealed class ImportMoviesCommandValidator : AbstractValidator<ImportMoviesCommand>
|
||||
{
|
||||
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("У фильма должен быть ровно один источник файла.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Заводит фильмы пачкой: шоу на файл, метаданные из источника, файл — в очередь обработки.
|
||||
///
|
||||
/// Фильмы разбираются независимо: один неподдерживаемый контейнер или пропавший из <c>manual/</c>
|
||||
/// файл не должен отменять остальные сорок — в разборе чужой библиотеки это частый случай, и
|
||||
/// заставлять человека повторять всю пачку из-за одной строки нельзя.
|
||||
/// </summary>
|
||||
public sealed class ImportMoviesCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IMediaStorage storage,
|
||||
IMediaProcessingQueue queue,
|
||||
ShowMetadataApplier applier
|
||||
) : ICommandHandler<ImportMoviesCommand, Result<ImportMoviesResultDto>>
|
||||
{
|
||||
public async Task<Result<ImportMoviesResultDto>> Handle(
|
||||
ImportMoviesCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var available = storage
|
||||
.ListManualInbox(int.MaxValue)
|
||||
.ToDictionary(f => f.RelativePath, StringComparer.Ordinal);
|
||||
|
||||
var failed = new List<MovieImportFailureDto>();
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Ассет фильма: уже загруженный браузером либо забранный из <c>manual/</c>.</summary>
|
||||
private async Task<(Guid AssetId, bool Enqueue)?> ResolveAssetAsync(
|
||||
MovieImportItem item,
|
||||
IReadOnlyDictionary<string, IMediaStorage.ManualInboxFile> available,
|
||||
List<MovieImportFailureDto> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при ручном разборе:
|
||||
/// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы
|
||||
/// не осталось записи без исходника.
|
||||
/// </summary>
|
||||
private async Task<Guid?> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using FluentValidation;
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Media.MovieImport;
|
||||
|
||||
/// <summary>
|
||||
/// Разбор пачки имён: что это за фильмы и что по ним нашлось в источнике. Имена приходят с клиента,
|
||||
/// а не читаются сервером, потому что источников два — каталог <c>manual/</c> и файлы, выбранные
|
||||
/// в браузере. Разбирать имя одинаково, а грузить файл только после того, как человек согласился
|
||||
/// со строкой, — единственный способ не заливать десятки гигабайт впустую.
|
||||
/// </summary>
|
||||
public sealed record MatchMoviesQuery(IReadOnlyList<string> Names, string Provider)
|
||||
: IQuery<IReadOnlyList<MovieMatchDto>>;
|
||||
|
||||
/// <summary>Перезапрос одной строки: админ поправил название (транслит, другое написание).</summary>
|
||||
public sealed record MatchMovieQuery(string Name, string Title, int? Year, string Provider)
|
||||
: IQuery<MovieMatchDto>;
|
||||
|
||||
public sealed class MatchMoviesQueryValidator : AbstractValidator<MatchMoviesQuery>
|
||||
{
|
||||
public MatchMoviesQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.Names).NotEmpty();
|
||||
// Потолок на пачку: каждая строка — запрос во внешний источник, и тысяча имён за раз
|
||||
// упрётся в его лимиты, а не в наши.
|
||||
RuleFor(x => x.Names.Count).LessThanOrEqualTo(200);
|
||||
RuleFor(x => x.Provider).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MatchMovieQueryValidator : AbstractValidator<MatchMovieQuery>
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using LiteCqrs;
|
||||
|
||||
namespace TeleWave.Application.Media.MovieImport;
|
||||
|
||||
public sealed class MatchMoviesQueryHandler(MovieMatcher matcher)
|
||||
: IQueryHandler<MatchMoviesQuery, IReadOnlyList<MovieMatchDto>>
|
||||
{
|
||||
public Task<IReadOnlyList<MovieMatchDto>> Handle(
|
||||
MatchMoviesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
) => matcher.MatchAsync(query.Names, query.Provider, cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MatchMovieQueryHandler(MovieMatcher matcher)
|
||||
: IQueryHandler<MatchMovieQuery, MovieMatchDto>
|
||||
{
|
||||
public Task<MovieMatchDto> Handle(MatchMovieQuery query, CancellationToken cancellationToken) =>
|
||||
matcher.MatchQueryAsync(
|
||||
query.Name,
|
||||
query.Title,
|
||||
query.Year,
|
||||
query.Provider,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Что стало с именем после разбора и поиска.</summary>
|
||||
public enum MovieMatchStatus
|
||||
{
|
||||
/// <summary>Название и год сошлись — можно импортировать не глядя.</summary>
|
||||
Confident,
|
||||
|
||||
/// <summary>Кандидаты есть, но точного совпадения нет: выбирать человеку.</summary>
|
||||
Uncertain,
|
||||
|
||||
/// <summary>Источник ничего не вернул — нужно вписать название руками.</summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>Такой фильм в библиотеке уже есть — по идентификатору источника.</summary>
|
||||
AlreadyInLibrary,
|
||||
}
|
||||
|
||||
/// <summary>Одна строка разбора: что распозналось, что нашлось и что с этим делать.</summary>
|
||||
public sealed record MovieMatchDto(
|
||||
/// <summary>Имя, как его прислал клиент, — по нему строка и опознаётся при импорте.</summary>
|
||||
string Name,
|
||||
string ParsedTitle,
|
||||
int? ParsedYear,
|
||||
MovieMatchStatus Status,
|
||||
IReadOnlyList<MetadataCandidate> Candidates,
|
||||
/// <summary>Кандидат, выбранный автоматически (только при уверенном совпадении).</summary>
|
||||
string? PickedExternalId,
|
||||
/// <summary>Шоу, которое уже занимает этого кандидата.</summary>
|
||||
Guid? ExistingShowId,
|
||||
string? ExistingShowName
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Подбор фильма по имени файла: разбор имени → поиск в источнике → решение, уверенное ли это
|
||||
/// совпадение. Один и тот же код обслуживает и кнопку разбора, и автоматику inbox: правило
|
||||
/// «что считать уверенным» обязано быть общим, иначе автоматика заведёт то, что таблица разбора
|
||||
/// показала бы жёлтым.
|
||||
///
|
||||
/// Уверенным считается совпадение, где нормализованное название сошлось дословно, год сошёлся
|
||||
/// с точностью до года (у релизов год проката и год производства разъезжаются) и такой кандидат
|
||||
/// ровно один. Всё остальное — человеку: цена ошибки здесь чужой постер и чужой рейтинг у фильма
|
||||
/// в эфире.
|
||||
/// </summary>
|
||||
public sealed class MovieMatcher(IAppDbContext dbContext, IMetadataProviderResolver resolver)
|
||||
{
|
||||
/// <summary>Сколько кандидатов показывать в строке: дальше третьего никто не смотрит.</summary>
|
||||
private const int MaxCandidates = 5;
|
||||
|
||||
/// <summary>Допуск по году. Больше единицы брать нельзя: сиквелы выходят с шагом в год.</summary>
|
||||
private const int YearTolerance = 1;
|
||||
|
||||
/// <summary>Шоу, уже занявшее кандидата источника.</summary>
|
||||
private sealed record TakenShow(Guid Id, string Name);
|
||||
|
||||
/// <summary>Разбор пачки имён: как их прислал клиент, так и вернутся — строка к строке.</summary>
|
||||
public async Task<IReadOnlyList<MovieMatchDto>> MatchAsync(
|
||||
IReadOnlyList<string> names,
|
||||
string providerKey,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (resolver.Resolve(providerKey) is not { } provider)
|
||||
return [];
|
||||
|
||||
var taken = await TakenAsync(providerKey, cancellationToken);
|
||||
var rows = new List<MovieMatchDto>(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;
|
||||
}
|
||||
|
||||
/// <summary>Подбор по названию, которое админ вписал руками вместо распознанного.</summary>
|
||||
public async Task<MovieMatchDto> 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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Уверенный кандидат для автоматики inbox. Возвращает null всегда, когда решение неочевидно, —
|
||||
/// молчаливый пропуск здесь лучше молчаливой ошибки: файл останется ассетом без шоу и попадёт
|
||||
/// в тот же разбор, что и всё непонятное.
|
||||
/// </summary>
|
||||
public async Task<MetadataCandidate?> 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<Dictionary<string, TakenShow>> 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<string, TakenShow>(StringComparer.Ordinal);
|
||||
foreach (var show in shows)
|
||||
taken.TryAdd(show.ExternalId, new TakenShow(show.Id, show.Name));
|
||||
|
||||
return taken;
|
||||
}
|
||||
|
||||
private static async Task<MovieMatchDto> MatchOneAsync(
|
||||
string name,
|
||||
ParsedMovieName parsed,
|
||||
IMetadataProvider provider,
|
||||
IReadOnlyDictionary<string, TakenShow> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Единственный кандидат, у которого название совпало дословно, а год сошёлся. Несколько таких —
|
||||
/// значит выбирать не из чего: два «Мстителя» одного года различит только человек.
|
||||
/// </summary>
|
||||
private static MetadataCandidate? Pick(
|
||||
IReadOnlyList<MetadataCandidate> 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);
|
||||
|
||||
/// <summary>Сравниваем по буквам и цифрам: «Spider-Man» и «Spider Man» — одно название.</summary>
|
||||
private static string Normalize(string value) =>
|
||||
string.Concat(value.Where(char.IsLetterOrDigit)).ToLowerInvariant();
|
||||
|
||||
/// <summary>Год без года не проверяется: имя без года — обычное дело, и отбрасывать его нельзя.</summary>
|
||||
private static bool YearFits(int? candidate, int? parsed) =>
|
||||
parsed is not { } year
|
||||
|| candidate is not { } found
|
||||
|| Math.Abs(found - year) <= YearTolerance;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace TeleWave.Application.Media.MovieImport;
|
||||
|
||||
/// <summary>Что удалось вытащить из имени файла или папки релиза.</summary>
|
||||
/// <param name="Title">Название для поиска в источнике; пустым не бывает.</param>
|
||||
/// <param name="Year">Год, если он в имени был. Главный признак совпадения при подборе.</param>
|
||||
public sealed record ParsedMovieName(string Title, int? Year);
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает имя релиза на название и год: «Voron.1994.TRIPLE.BDRip.XviD.AC3» → «Voron» + 1994.
|
||||
///
|
||||
/// Порядок разбора не случаен. Сначала отрезается хвост релиза по первому же техническому маркеру
|
||||
/// (rip, кодек, разрешение, «by_...»), и только потом в остатке ищется год: иначе «Edward
|
||||
/// Scissorhands by_IVAN@1990» дало бы название «Edward Scissorhands by IVAN@», а год в никнейме
|
||||
/// релизера сошёл бы за год выпуска.
|
||||
///
|
||||
/// Разбор заведомо неточен — имена пишут люди и роботы десятка трекеров, — поэтому он и не обязан
|
||||
/// быть точным: его результат попадает в таблицу, где строку правят руками. Задача парсера — чтобы
|
||||
/// правил человек единицы строк, а не все.
|
||||
/// </summary>
|
||||
public static partial class MovieNameParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Технические маркеры релиза: с первого из них начинается хвост, к названию не относящийся.
|
||||
/// Сравниваются целыми словами, поэтому «Triple» из «TRIPLERUS» сюда не попадёт — и правильно,
|
||||
/// такие склейки к названию тоже не относятся, но резать по ним рискованно.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlySet<string> Markers = new HashSet<string>(
|
||||
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",
|
||||
};
|
||||
|
||||
/// <summary>Символы, которые остаются висеть на краях названия после отрезания хвоста.</summary>
|
||||
private static readonly char[] Edges =
|
||||
[
|
||||
'-',
|
||||
'–',
|
||||
'—',
|
||||
'_',
|
||||
'.',
|
||||
',',
|
||||
':',
|
||||
';',
|
||||
'(',
|
||||
'[',
|
||||
'{',
|
||||
')',
|
||||
']',
|
||||
'}',
|
||||
'\'',
|
||||
];
|
||||
|
||||
[GeneratedRegex(@"(?<!\d)(19|20)\d{2}(?!\d)", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex YearPattern();
|
||||
|
||||
[GeneratedRegex(@"\s{2,}", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ExtraSpaces();
|
||||
|
||||
/// <summary>
|
||||
/// Разбирает имя файла или папки. Расширение отрезается только знакомое: у папки
|
||||
/// «Voron.1994.TRIPLE.BDRip.XviD.AC3» иначе «расширением» стало бы «.AC3», а с ним ушёл бы
|
||||
/// кусок имени.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Часть имени до первого технического маркера.</summary>
|
||||
private static string Head(string text)
|
||||
{
|
||||
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var taken = new List<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Год выпуска и всё, что стоит до него. Год в самом начале не в счёт: «2012 2009 BDRip» —
|
||||
/// это фильм «2012» девятого года, а не наоборот.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пробует завести фильм по имени файла. Неуверенное совпадение — не ошибка: ассет останется
|
||||
/// без шоу и попадёт в разбор. Сбой источника метаданных тоже не должен ронять сканер — файл
|
||||
/// уже зарегистрирован, и это главное.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,14 @@ public sealed class MediaOptions
|
||||
/// <summary>Период опроса inbox/ сканером, секунды.</summary>
|
||||
public int InboxScanSeconds { get; init; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Заводить фильм по файлу из inbox/ автоматически, когда имя уверенно опознано в источнике
|
||||
/// метаданных. Неуверенное совпадение всё равно остаётся человеку — файл станет ассетом без шоу
|
||||
/// и попадёт в разбор. Выключить стоит, если inbox наполняется сериалами: у них имя опознаётся
|
||||
/// как фильм ровно никогда, и лишние запросы к источнику ни к чему.
|
||||
/// </summary>
|
||||
public bool InboxAutoMovies { get; init; } = true;
|
||||
|
||||
/// <summary>Нормализовать громкость при обработке (ffmpeg loudnorm, EBU R128) — чтобы все ролики
|
||||
/// звучали одинаково.</summary>
|
||||
public bool NormalizeLoudness { get; init; } = true;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user