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

Added new endpoints for matching and importing movies, allowing for automated processing of film files from the inbox. Introduced logic to parse file names into titles and years, and integrated metadata matching to streamline the import process. Updated the MediaOptions to include a configuration for automatic movie creation from recognized files. Enhanced the InboxScanner to attempt movie attachment upon asset registration, improving user experience and efficiency. Updated documentation to reflect these new features and their usage.
This commit is contained in:
Leonid Pershin
2026-07-28 03:02:30 +03:00
parent e48838b69d
commit d859a9894c
25 changed files with 2144 additions and 4 deletions
+3
View File
@@ -66,6 +66,9 @@ Media__TranscodeThreads=3
Media__MaxParallelTranscodes=1 Media__MaxParallelTranscodes=1
# Период опроса каталога inbox/ сканером, секунды. # Период опроса каталога inbox/ сканером, секунды.
Media__InboxScanSeconds=15 Media__InboxScanSeconds=15
# Заводить фильм по файлу из inbox/, когда имя уверенно опознано в источнике метаданных.
# Неуверенное совпадение остаётся человеку: файл станет ассетом без шоу и попадёт в разбор.
Media__InboxAutoMovies=true
# Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости. # Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости.
Media__NormalizeLoudness=true Media__NormalizeLoudness=true
# Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить). # Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить).
@@ -8,6 +8,7 @@ using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete; using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.ListMedia; using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox; using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Media.Register; using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Stats; using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
@@ -33,6 +34,12 @@ public static class MediaEndpoints
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>(); admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>(); 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. // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
@@ -164,6 +171,45 @@ public static class MediaEndpoints
return Results.Ok(result); 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( private static async Task<IResult> ImportManual(
ImportManualInboxBody body, ImportManualInboxBody body,
ISender sender, ISender sender,
@@ -228,3 +274,9 @@ public sealed record ListMediaFilter(
public sealed record UploadMediaResponse(Guid Id); public sealed record UploadMediaResponse(Guid Id);
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId); 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.Common.Behaviors;
using TeleWave.Application.Library.Collections.Suggest; using TeleWave.Application.Library.Collections.Suggest;
using TeleWave.Application.Library.Genres; using TeleWave.Application.Library.Genres;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Metadata; using TeleWave.Application.Metadata;
using TeleWave.Application.Programming.Groups; using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.Suggest; using TeleWave.Application.Programming.Groups.Suggest;
@@ -40,6 +41,7 @@ public static class DependencyInjection
services.AddScoped<BumperSpecLoader>(); services.AddScoped<BumperSpecLoader>();
services.AddScoped<GenreMatcher>(); services.AddScoped<GenreMatcher>();
services.AddScoped<ShowMetadataApplier>(); services.AddScoped<ShowMetadataApplier>();
services.AddScoped<MovieMatcher>();
services.AddScoped<CollectionSuggestionBuilder>(); services.AddScoped<CollectionSuggestionBuilder>();
services.AddScoped<GroupElementResolver>(); services.AddScoped<GroupElementResolver>();
services.AddScoped<GroupFilterMatcher>(); services.AddScoped<GroupFilterMatcher>();
@@ -43,4 +43,9 @@ public static class MediaErrors
"Media.DuplicateFileName", "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 Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces; using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media; using TeleWave.Application.Media;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Media.Register; using TeleWave.Application.Media.Register;
using TeleWave.Domain.Media; using TeleWave.Domain.Media;
@@ -128,6 +129,7 @@ public sealed class InboxScannerBackgroundService(
result.Value, result.Value,
fileName fileName
); );
await AttachMovieAsync(sender, result.Value, fileName, cancellationToken);
return true; return true;
} }
@@ -138,4 +140,35 @@ public sealed class InboxScannerBackgroundService(
); );
return false; 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> /// <summary>Период опроса inbox/ сканером, секунды.</summary>
public int InboxScanSeconds { get; init; } = 15; public int InboxScanSeconds { get; init; } = 15;
/// <summary>
/// Заводить фильм по файлу из inbox/ автоматически, когда имя уверенно опознано в источнике
/// метаданных. Неуверенное совпадение всё равно остаётся человеку — файл станет ассетом без шоу
/// и попадёт в разбор. Выключить стоит, если inbox наполняется сериалами: у них имя опознаётся
/// как фильм ровно никогда, и лишние запросы к источнику ни к чему.
/// </summary>
public bool InboxAutoMovies { get; init; } = true;
/// <summary>Нормализовать громкость при обработке (ffmpeg loudnorm, EBU R128) — чтобы все ролики /// <summary>Нормализовать громкость при обработке (ffmpeg loudnorm, EBU R128) — чтобы все ролики
/// звучали одинаково.</summary> /// звучали одинаково.</summary>
public bool NormalizeLoudness { get; init; } = true; 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);
}
}
+40
View File
@@ -203,6 +203,45 @@ Inbox-сканер — второй `BackgroundService`: замечает нов
`MediaAsset` в `Pending` и ставит в очередь. Из inbox файлы попадают в библиотеку как отдельные `MediaAsset` в `Pending` и ставит в очередь. Из inbox файлы попадают в библиотеку как отдельные
ассеты; привязка к шоу/сериям — уже действие админа в UI. ассеты; привязка к шоу/сериям — уже действие админа в UI.
## Разбор фильмов
Сериал грузится пачкой файлов **в одно шоу** — этим занят ручной разбор `manual/`. У полнометражек
наоборот: файл — это отдельное шоу, и вручную заводить сорок штук никто не станет. Отсюда отдельный
разбор, у которого три входа и один общий код.
**Разбор имени** (`MovieNameParser`) даёт название и год. Порядок: сначала отрезается хвост релиза
по первому техническому маркеру (rip, кодек, разрешение, `by_...`), и только в остатке ищется год —
иначе «Edward Scissorhands by_IVAN@1990» дал бы название с ником релизера, а год из ника сошёл бы
за год выпуска. Год в начале имени годом не считается: «2012.2009.BDRip» — это фильм «2012» девятого
года. Первое слово маркером не считается («Extended Family» существует), а `web` и `rip` из списка
маркеров исключены — «Charlotte's Web» дороже лишнего хвоста. Папка релиза разбирается по имени
папки: оно чище, чем `video.mkv` внутри, а файлом берётся самый большой (остальное — сэмплы).
**Подбор** (`MovieMatcher`) ищет по названию среди полнометражек источника и решает, уверенное ли
это совпадение: нормализованное название сошлось дословно, год сошёлся с точностью до года (год
проката и год производства расходятся) и такой кандидат ровно один. Правило то же, что у массового
обогащения из 6.x, и по той же причине: чужой постер и чужой рейтинг у фильма в эфире хуже, чем их
отсутствие. Статусы строки — «совпадение точное», «проверьте», «не найдено», «уже в библиотеке»
(по идентификатору источника).
**Три входа, одно правило:**
- **Кнопка, файлы из `manual/`.** Таблица разбора: строку правят руками (транслитные имена вроде
`Shou.Trumana` источник не находит — впишите название и перезапросите одну строку), затем импорт
заводит шоу, применяет метаданные и отправляет файл в очередь.
- **Кнопка, файлы с диска.** Имена разбираются **до** загрузки: узнать, что половина строк не
распозналась, после заливки десятков гигабайт — слишком поздно. Подтверждённые файлы грузятся
по одному, и каждый заводится сразу после своего аплоада.
- **Автоматика inbox.** Сканер после регистрации ассета пробует опознать фильм и заводит шоу
**только при уверенном совпадении** (`Media__InboxAutoMovies`, по умолчанию включено). Всё
неуверенное молча остаётся ассетом без шоу и попадает в ту же таблицу разбора — автоматика
обязана молчать там, где таблица показала бы жёлтым.
Шоу импорт создаёт, а **шоу-контент — никогда**: файл приносит загрузка, и придуманное источником
название не должно превращаться в запись библиотеки без файла. Франшизы отдельным шагом не
собираются: `FranchiseExternalId` проставляется вместе с метаданными, а коллекции предлагает
существующий `CollectionSuggestionBuilder` — там виден состав, и спин-оффы TMDb отсекает человек.
## Слои и API ## Слои и API
**Application** — порты `IMediaStorage` (резолв путей, запись, удаление, свободное место), **Application** — порты `IMediaStorage` (резолв путей, запись, удаление, свободное место),
@@ -231,6 +270,7 @@ Storage__StaleUploadHours=24
Media__FfmpegPath=/usr/bin/ffmpeg Media__FfmpegPath=/usr/bin/ffmpeg
Media__FfprobePath=/usr/bin/ffprobe Media__FfprobePath=/usr/bin/ffprobe
Media__MaxUploadBytes=21474836480 Media__MaxUploadBytes=21474836480
Media__InboxAutoMovies=true
Scheduler__HorizonDays=7 Scheduler__HorizonDays=7
Scheduler__RetentionDays=90 Scheduler__RetentionDays=90
Scheduler__TickMinutes=30 Scheduler__TickMinutes=30
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FolderInput, ListPlus, Upload } from 'lucide-react' import { Clapperboard, FolderInput, ListPlus, Upload } from 'lucide-react'
import { qk } from '@/shared/api/query-keys' import { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error' import { useApiError } from '@/shared/lib/use-api-error'
@@ -15,6 +15,7 @@ import { SortHeader } from '@/shared/ui/sortable'
import { deleteMedia, getMediaStats, listMedia } from './api' import { deleteMedia, getMediaStats, listMedia } from './api'
import { formatDuration, splitEta } from './format' import { formatDuration, splitEta } from './format'
import { ManualInboxDialog } from './ManualInboxDialog' import { ManualInboxDialog } from './ManualInboxDialog'
import { MovieImportDialog } from './MovieImportDialog'
import { UploadToShowDialog } from './UploadToShowDialog' import { UploadToShowDialog } from './UploadToShowDialog'
import { useUploadStore } from './upload-store' import { useUploadStore } from './upload-store'
@@ -48,6 +49,7 @@ export function MediaPanel() {
const { sort, toggle } = useTableSort('created', true) const { sort, toggle } = useTableSort('created', true)
const [filesForShow, setFilesForShow] = useState<File[] | null>(null) const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
const [manualOpen, setManualOpen] = useState(false) const [manualOpen, setManualOpen] = useState(false)
const [moviesOpen, setMoviesOpen] = useState(false)
const enqueue = useUploadStore((s) => s.enqueue) const enqueue = useUploadStore((s) => s.enqueue)
const sortColumn = (key: string) => { const sortColumn = (key: string) => {
@@ -182,6 +184,10 @@ export function MediaPanel() {
e.target.value = '' e.target.value = ''
}} }}
/> />
<Button size="sm" variant="outline" onClick={() => setMoviesOpen(true)}>
<Clapperboard className="h-4 w-4" />
{t('admin.media.moviesButton')}
</Button>
<Button size="sm" variant="outline" onClick={() => setManualOpen(true)}> <Button size="sm" variant="outline" onClick={() => setManualOpen(true)}>
<FolderInput className="h-4 w-4" /> <FolderInput className="h-4 w-4" />
{t('admin.media.manualButton')} {t('admin.media.manualButton')}
@@ -202,6 +208,7 @@ export function MediaPanel() {
)} )}
{manualOpen && <ManualInboxDialog onClose={() => setManualOpen(false)} />} {manualOpen && <ManualInboxDialog onClose={() => setManualOpen(false)} />}
{moviesOpen && <MovieImportDialog onClose={() => setMoviesOpen(false)} />}
<div className="crt-panel overflow-x-auto rounded-md"> <div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -0,0 +1,379 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Check, HelpCircle, Search, Upload } from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getMetadataProviders } from '@/features/admin/shows/api'
import { qk } from '@/shared/api/query-keys'
import type { MovieImportItem, MovieMatchDto, MovieMatchStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { importMovies, listManualInbox, matchMovie, matchMovies } from './api'
import { useUploadStore } from './upload-store'
type Source = 'manual' | 'disk'
/** Строка таблицы: разбор от сервера плюс то, что человек в ней поправил. */
type Row = MovieMatchDto & {
/** Ключ строки — имя файла или папки; по нему же строка опознаётся при импорте. */
selected: boolean
chosenExternalId: string | null
title: string
year: string
}
const STATUS_TONE: Record<MovieMatchStatus, string> = {
Confident: 'text-emerald-500',
Uncertain: 'text-amber-500',
NotFound: 'text-red-500',
AlreadyInLibrary: 'text-muted-foreground',
}
function toRow(match: MovieMatchDto): Row {
return {
...match,
// Уже заведённое и ненайденное по умолчанию не отмечаем: первое создало бы дубль,
// второе — шоу без метаданных, которого человек не просил.
selected: match.status === 'Confident' || match.status === 'Uncertain',
chosenExternalId: match.pickedExternalId,
title: match.parsedTitle,
year: match.parsedYear?.toString() ?? '',
}
}
/**
* Файлы `manual/` строками: папка релиза — это один фильм, а не набор файлов. Внутри папки берём
* самый большой видеофайл (остальное — сэмплы и трейлеры), а показываем имя папки: оно чище,
* чем «video.mkv» внутри.
*/
function manualRows(
files: { relativePath: string; folder: string; name: string; sizeBytes: number }[],
) {
const rows = new Map<string, { name: string; relativePath: string; sizeBytes: number }>()
for (const file of files) {
const key = file.folder || file.relativePath
const name = file.folder || file.name
const current = rows.get(key)
if (!current || file.sizeBytes > current.sizeBytes)
rows.set(key, { name, relativePath: file.relativePath, sizeBytes: file.sizeBytes })
}
return [...rows.values()]
}
/**
* Разбор фильмов: имя файла → название и год → кандидат источника → шоу с метаданными.
*
* Источника два и оба идут одной таблицей: файлы из `manual/` на сервере и выбранные в браузере.
* Во втором случае имена разбираются **до** загрузки — иначе, чтобы узнать, что половина строк
* не распозналась, пришлось бы сначала залить десятки гигабайт.
*/
export function MovieImportDialog({ onClose }: Readonly<{ onClose: () => void }>) {
const { t } = useTranslation()
const onError = useApiError()
const queryClient = useQueryClient()
const [source, setSource] = useState<Source>('manual')
const [rows, setRows] = useState<Row[]>([])
const [files, setFiles] = useState<File[]>([])
const fileInput = useRef<HTMLInputElement>(null)
const enqueue = useUploadStore((s) => s.enqueue)
const { data: providers } = useQuery({
queryKey: qk.metadata.providers,
queryFn: getMetadataProviders,
})
const [provider, setProvider] = useState('')
const activeProvider = provider || providers?.[0] || ''
const { data: manual } = useQuery({
queryKey: qk.media.manual,
queryFn: listManualInbox,
enabled: source === 'manual',
})
const manualFiles = useMemo(() => manualRows(manual?.files ?? []), [manual])
/** Имя строки → чем её импортировать: путь в manual/ либо файл из браузера. */
const sources = useMemo(() => {
const map = new Map<string, { relativePath?: string; file?: File }>()
if (source === 'manual')
for (const item of manualFiles) map.set(item.name, { relativePath: item.relativePath })
else for (const file of files) map.set(file.name, { file })
return map
}, [source, manualFiles, files])
const scan = useMutation({
mutationFn: () => matchMovies([...sources.keys()], activeProvider),
onSuccess: (matches) => setRows(matches.map(toRow)),
onError,
})
const research = useMutation({
mutationFn: (row: Row) =>
matchMovie(row.name, row.title, row.year ? Number(row.year) : null, activeProvider),
onSuccess: (match) =>
setRows((current) =>
current.map((row) => (row.name === match.name ? { ...toRow(match), selected: true } : row)),
),
onError,
})
const importing = useMutation({
mutationFn: async () => {
const chosen = rows.filter((row) => row.selected)
// Файлы с диска грузятся по одному и заводятся сразу после аплоада: ждать всю пачку,
// чтобы увидеть первый фильм в библиотеке, незачем.
if (source === 'disk') {
const plans = new Map(
chosen.map((row) => [
row.name,
{
title: row.title,
year: row.year ? Number(row.year) : null,
externalId: row.chosenExternalId,
},
]),
)
await enqueue(
chosen.map((row) => sources.get(row.name)?.file).filter((f): f is File => Boolean(f)),
{ resolveMovie: (file) => plans.get(file.name), provider: activeProvider },
)
return null
}
const items: MovieImportItem[] = chosen.map((row) => ({
relativePath: sources.get(row.name)?.relativePath ?? null,
assetId: null,
title: row.title,
year: row.year ? Number(row.year) : null,
externalId: row.chosenExternalId,
}))
return importMovies(items, activeProvider)
},
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: qk.media.all })
void queryClient.invalidateQueries({ queryKey: qk.media.manual })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
if (!result) {
toast.success(t('admin.movies.uploadStarted'))
onClose()
return
}
toast.success(
t('admin.movies.imported', { created: result.created, enriched: result.enriched }),
)
for (const failure of result.failed) toast.error(`${failure.title}: ${failure.reason}`)
setRows((current) => current.filter((row) => !row.selected))
},
onError,
})
const patch = (name: string, changes: Partial<Row>) =>
setRows((current) => current.map((row) => (row.name === name ? { ...row, ...changes } : row)))
const selectedCount = rows.filter((row) => row.selected).length
const canScan = sources.size > 0 && Boolean(activeProvider) && !scan.isPending
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>{t('admin.movies.title')}</DialogTitle>
<DialogDescription>{t('admin.movies.hint')}</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap items-center gap-2 text-sm">
<div className="flex gap-1">
{(['manual', 'disk'] as Source[]).map((item) => (
<button
key={item}
type="button"
onClick={() => {
setSource(item)
setRows([])
}}
className={`rounded border px-2 py-1 text-xs ${
source === item
? 'border-primary text-primary'
: 'border-border text-muted-foreground'
}`}
>
{t(`admin.movies.sources.${item}`)}
</button>
))}
</div>
{source === 'disk' && (
<>
<input
ref={fileInput}
type="file"
multiple
accept="video/*,.mkv,.avi,.mp4,.m4v,.mov,.ts,.mpg,.mpeg,.wmv,.flv"
className="hidden"
onChange={(e) => {
setFiles([...(e.target.files ?? [])])
setRows([])
}}
/>
<Button size="sm" variant="outline" onClick={() => fileInput.current?.click()}>
<Upload className="h-4 w-4" />
{t('admin.movies.pickFiles')}
</Button>
</>
)}
{(providers?.length ?? 0) > 1 && (
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={activeProvider}
onChange={(e) => setProvider(e.target.value)}
>
{providers?.map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
)}
<Button size="sm" disabled={!canScan} onClick={() => scan.mutate()}>
<Search className="h-4 w-4" />
{t('admin.movies.scan', { count: sources.size })}
</Button>
{!activeProvider && (
<span className="flex items-center gap-1.5 text-xs text-amber-500">
<AlertTriangle className="h-3.5 w-3.5" />
{t('admin.movies.noProvider')}
</span>
)}
</div>
<div className="max-h-[55vh] overflow-auto rounded border border-border">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-background text-xs uppercase text-muted-foreground">
<tr>
<th className="w-8 p-2" />
<th className="p-2 text-left">{t('admin.movies.file')}</th>
<th className="p-2 text-left">{t('admin.movies.parsed')}</th>
<th className="p-2 text-left">{t('admin.movies.candidate')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.name} className="border-t border-border align-top">
<td className="p-2">
<input
type="checkbox"
checked={row.selected}
onChange={(e) => patch(row.name, { selected: e.target.checked })}
/>
</td>
<td className="max-w-[18rem] truncate p-2 font-mono text-xs" title={row.name}>
{row.name}
<div
className={`mt-1 flex items-center gap-1 text-[11px] ${STATUS_TONE[row.status]}`}
>
{row.status === 'Confident' && <Check className="h-3 w-3" />}
{row.status === 'Uncertain' && <HelpCircle className="h-3 w-3" />}
{row.status === 'NotFound' && <AlertTriangle className="h-3 w-3" />}
{t(`admin.movies.statuses.${row.status}`)}
{row.existingShowName && <span>· {row.existingShowName}</span>}
</div>
</td>
<td className="p-2">
<div className="flex items-center gap-1.5">
<Input
className="h-8"
value={row.title}
onChange={(e) => patch(row.name, { title: e.target.value })}
/>
<Input
className="h-8 w-20"
placeholder={t('admin.movies.year')}
value={row.year}
onChange={(e) => patch(row.name, { year: e.target.value })}
/>
<Button
size="sm"
variant="ghost"
disabled={research.isPending || !row.title.trim()}
onClick={() => research.mutate(row)}
title={t('admin.movies.research')}
>
<Search className="h-4 w-4" />
</Button>
</div>
</td>
<td className="p-2">
{row.candidates.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t('admin.movies.empty')}
</span>
) : (
<select
className="h-8 w-full rounded-md border border-border bg-transparent px-2 text-sm"
value={row.chosenExternalId ?? ''}
onChange={(e) =>
patch(row.name, { chosenExternalId: e.target.value || null })
}
>
<option value="">{t('admin.movies.noCandidate')}</option>
{row.candidates.map((candidate) => (
<option key={candidate.externalId} value={candidate.externalId}>
{candidate.title}
{candidate.year ? ` (${candidate.year})` : ''}
</option>
))}
</select>
)}
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="p-6 text-center text-sm text-muted-foreground">
{scan.isPending ? t('admin.movies.scanning') : t('admin.movies.nothingYet')}
</td>
</tr>
)}
</tbody>
</table>
</div>
{rows.length > 0 && (
<p className="text-xs text-muted-foreground">{t('admin.movies.collectionsHint')}</p>
)}
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose}>
{t('common.close')}
</Button>
<Button
size="sm"
disabled={selectedCount === 0 || importing.isPending}
onClick={() => importing.mutate()}
>
{t('admin.movies.import', { count: selectedCount })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+33
View File
@@ -2,6 +2,9 @@ import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type { import type {
CreatedIdResponse, CreatedIdResponse,
ImportManualInboxResultDto, ImportManualInboxResultDto,
ImportMoviesResultDto,
MovieImportItem,
MovieMatchDto,
ManualImportItem, ManualImportItem,
ManualInboxListDto, ManualInboxListDto,
MediaAssetDto, MediaAssetDto,
@@ -128,3 +131,33 @@ export function uploadMedia(
xhr.send(file) xhr.send(file)
}) })
} }
// ── Разбор фильмов ────────────────────────────────────────────────────────
/**
* Разбирает имена и ищет кандидатов в источнике. Имена шлём с клиента, потому что источника два:
* каталог `manual/` и файлы, выбранные в браузере, — а разбирать их надо одинаково и **до** того,
* как что-то поедет на сервер.
*/
export function matchMovies(names: string[], provider: string) {
return apiRequest<MovieMatchDto[]>('/admin/media/movies/match', {
method: 'POST',
body: { names, provider },
})
}
/** Перезапрос одной строки: админ поправил название (транслит, другое написание). */
export function matchMovie(name: string, title: string, year: number | null, provider: string) {
return apiRequest<MovieMatchDto>('/admin/media/movies/match-one', {
method: 'POST',
body: { name, title, year, provider },
})
}
/** Заводит фильмы: шоу на файл, метаданные по выбранному кандидату, файл — в очередь обработки. */
export function importMovies(items: MovieImportItem[], provider: string) {
return apiRequest<ImportMoviesResultDto>('/admin/media/movies/import', {
method: 'POST',
body: { items, provider },
})
}
@@ -6,7 +6,7 @@ import { importInterstitials } from '@/features/admin/interstitials/api'
import { addEpisode } from '@/features/admin/shows/api' import { addEpisode } from '@/features/admin/shows/api'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import type { CreatedIdResponse } from '@/shared/api/types' import type { CreatedIdResponse } from '@/shared/api/types'
import { listMedia, uploadMedia } from './api' import { importMovies, listMedia, uploadMedia } from './api'
export type UploadItem = { export type UploadItem = {
id: string id: string
@@ -38,11 +38,28 @@ type EnqueueOptions = {
resolveShowId?: (file: File) => string | undefined resolveShowId?: (file: File) => string | undefined
/** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */ /** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */
interstitial?: boolean interstitial?: boolean
/**
* Разбор фильмов: по каждому файлу заводится своё шоу с выбранными в таблице названием и
* кандидатом источника. Возвращает undefined — файл грузится как обычно, без шоу.
*/
resolveMovie?: (file: File) => MoviePlan | undefined
/** Источник метаданных для заведения фильмов — тот, что выбран в таблице разбора. */
provider?: string
} }
/** Что известно про фильм к моменту загрузки файла: строку таблицы уже подтвердили. */
export type MoviePlan = { title: string; year: number | null; externalId: string | null }
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации. // Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
let counter = 0 let counter = 0
type Job = { id: string; file: File; showId?: string; interstitial?: boolean } type Job = {
id: string
file: File
showId?: string
interstitial?: boolean
movie?: MoviePlan
provider?: string
}
const queue: Job[] = [] const queue: Job[] = []
const controllers = new Map<string, AbortController>() const controllers = new Map<string, AbortController>()
const failed = new Map<string, Job>() // упавшие — для ручного повтора const failed = new Map<string, Job>() // упавшие — для ручного повтора
@@ -109,6 +126,26 @@ async function linkUploaded(job: Job, assetId: string) {
} catch { } catch {
toast.error(`${job.file.name}: не удалось добавить в шоу`) toast.error(`${job.file.name}: не удалось добавить в шоу`)
} }
} else if (job.movie && job.provider) {
// Фильм заводится сразу после аплоада: строку уже подтвердили в таблице разбора, и ждать
// окончания всей пачки незачем — библиотека наполняется по мере загрузки.
try {
await importMovies(
[
{
relativePath: null,
assetId,
title: job.movie.title,
year: job.movie.year,
externalId: job.movie.externalId,
},
],
job.provider,
)
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
} catch {
toast.error(`${job.file.name}: не удалось завести фильм`)
}
} else if (job.interstitial) { } else if (job.interstitial) {
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается. // Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
try { try {
@@ -180,7 +217,14 @@ export const useUploadStore = create<UploadStore>((set) => ({
const newItems: UploadItem[] = toAdd.map((file) => { const newItems: UploadItem[] = toAdd.map((file) => {
const id = `u${++counter}` const id = `u${++counter}`
const showId = options?.resolveShowId?.(file) ?? options?.showId const showId = options?.resolveShowId?.(file) ?? options?.showId
queue.push({ id, file, showId, interstitial: options?.interstitial }) queue.push({
id,
file,
showId,
interstitial: options?.interstitial,
movie: options?.resolveMovie?.(file),
provider: options?.provider,
})
return { id, name: file.name, percent: 0, status: 'queued' } return { id, name: file.name, percent: 0, status: 'queued' }
}) })
+38
View File
@@ -81,6 +81,44 @@ export type ManualImportItem = {
episode: number | null episode: number | null
} }
/** Кандидат из источника метаданных — то же, что показывает поиск на карточке шоу. */
export type MetadataCandidateDto = {
externalId: string
title: string
year: number | null
overview: string | null
posterUrl: string | null
}
export type MovieMatchStatus = 'Confident' | 'Uncertain' | 'NotFound' | 'AlreadyInLibrary'
/** Строка разбора фильмов: что распозналось в имени и что по нему нашлось. */
export type MovieMatchDto = {
name: string
parsedTitle: string
parsedYear: number | null
status: MovieMatchStatus
candidates: MetadataCandidateDto[]
pickedExternalId: string | null
existingShowId: string | null
existingShowName: string | null
}
/** Фильм к заведению: файл берётся из manual/ (relativePath) либо уже загружен (assetId). */
export type MovieImportItem = {
relativePath: string | null
assetId: string | null
title: string
year: number | null
externalId: string | null
}
export type ImportMoviesResultDto = {
created: number
enriched: number
failed: { title: string; reason: string }[]
}
export type ManualInboxListDto = { export type ManualInboxListDto = {
files: ManualInboxFileDto[] files: ManualInboxFileDto[]
/** Выдача обрезана лимитом — в каталоге есть ещё. */ /** Выдача обрезана лимитом — в каталоге есть ещё. */
+29
View File
@@ -245,10 +245,39 @@ export const en = {
create: 'Create', create: 'Create',
created: 'User created', created: 'User created',
}, },
movies: {
title: 'Movie import',
hint: 'The file name is parsed into a title and a year, and the movie is looked up in the metadata source. Every file becomes its own show; fix the rows where the match is not exact.',
sources: { manual: 'From manual folder', disk: 'From my disk' },
pickFiles: 'Pick files',
scan: 'Parse ({{count}})',
scanning: 'Searching the source…',
nothingYet: 'Pick a source and press "Parse".',
noProvider: 'No metadata source configured — there is nothing to search with.',
file: 'File',
parsed: 'Title and year',
candidate: 'Candidate',
year: 'Year',
research: 'Search again',
empty: 'nothing found',
noCandidate: 'no metadata',
import: 'Create ({{count}})',
imported: 'Movies created: {{created}}, with metadata: {{enriched}}',
uploadStarted: 'Files are uploading — movies appear as they finish',
collectionsHint:
'Franchises turn into collections after the import — the suggestions wait on the Collections tab.',
statuses: {
Confident: 'exact match',
Uncertain: 'check this',
NotFound: 'not found — type the title',
AlreadyInLibrary: 'already in the library',
},
},
media: { media: {
title: 'Media', title: 'Media',
upload: 'Upload', upload: 'Upload',
manualButton: 'From manual folder', manualButton: 'From manual folder',
moviesButton: 'Movies',
manualTitle: 'Manual pick from the manual folder', manualTitle: 'Manual pick from the manual folder',
manualHint: manualHint:
'Files in manual/ are not picked up by the scanner — select the ones you need and choose a show. Imported files leave the folder, just like from inbox.', 'Files in manual/ are not picked up by the scanner — select the ones you need and choose a show. Imported files leave the folder, just like from inbox.',
+29
View File
@@ -246,10 +246,39 @@ export const ru = {
create: 'Создать', create: 'Создать',
created: 'Пользователь создан', created: 'Пользователь создан',
}, },
movies: {
title: 'Разбор фильмов',
hint: 'Имя файла разбирается на название и год, по ним ищется фильм в источнике метаданных. Каждый файл становится отдельным шоу; строки, где совпадение неточное, поправьте руками.',
sources: { manual: 'Из папки manual', disk: 'С моего диска' },
pickFiles: 'Выбрать файлы',
scan: 'Разобрать ({{count}})',
scanning: 'Ищем в источнике…',
nothingYet: 'Выберите источник и нажмите «Разобрать».',
noProvider: 'Источник метаданных не настроен — без него искать нечем.',
file: 'Файл',
parsed: 'Название и год',
candidate: 'Кандидат',
year: 'Год',
research: 'Искать заново',
empty: 'ничего не нашлось',
noCandidate: 'без метаданных',
import: 'Завести ({{count}})',
imported: 'Заведено фильмов: {{created}}, с метаданными: {{enriched}}',
uploadStarted: 'Файлы пошли на загрузку — фильмы заведутся по мере готовности',
collectionsHint:
'Франшизы после импорта соберутся в коллекции — предложения ждут на вкладке «Коллекции».',
statuses: {
Confident: 'совпадение точное',
Uncertain: 'проверьте',
NotFound: 'не найдено — впишите название',
AlreadyInLibrary: 'уже в библиотеке',
},
},
media: { media: {
title: 'Медиа', title: 'Медиа',
upload: 'Загрузить', upload: 'Загрузить',
manualButton: 'Из папки manual', manualButton: 'Из папки manual',
moviesButton: 'Фильмы',
manualTitle: 'Ручной разбор папки manual', manualTitle: 'Ручной разбор папки manual',
manualHint: manualHint:
'Файлы из manual/ не разбираются сканером — выберите нужные и укажите шоу. Импортированные файлы уходят из папки, как и из inbox.', 'Файлы из manual/ не разбираются сканером — выберите нужные и укажите шоу. Импортированные файлы уходят из папки, как и из inbox.',