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;
|
||||
|
||||
Reference in New Issue
Block a user