Refactor manual inbox handling: update ImportManualInboxCommand to accept ManualImportItem objects, enhancing file import functionality with explicit season and episode numbers. Implement CleanupManualLeftoversAsync in IMediaStorage to remove unnecessary files after import. Update frontend components to support new import structure and improve user experience in manual media management.
build / backend (push) Successful in 1m11s
build / frontend (push) Successful in 34s
tests / backend-tests (push) Successful in 1m38s

This commit is contained in:
Leonid Pershin
2026-07-26 15:41:31 +03:00
parent 74bca20a3c
commit a95c0540d9
13 changed files with 585 additions and 90 deletions
@@ -163,7 +163,7 @@ public static class MediaEndpoints
)
{
var result = await sender.Send(
new ImportManualInboxCommand(body.RelativePaths, body.ShowId),
new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
@@ -222,4 +222,4 @@ public static class MediaEndpoints
public sealed record UploadMediaResponse(Guid Id);
public sealed record ImportManualInboxBody(IReadOnlyList<string> RelativePaths, Guid ShowId);
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
@@ -34,6 +34,13 @@ public interface IMediaStorage
/// </summary>
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary>
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
/// не трогает — рядом может лежать следующая серия.
/// </summary>
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
/// <summary>
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
@@ -5,11 +5,19 @@ using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Media.ManualInbox;
/// <summary>
/// Забирает выбранные файлы из <c>manual/</c> в шоу. Файлы уходят из каталога, как и из обычного
/// <c>inbox/</c>. Шоу обязательно: ручной разбор затевается ровно ради того, чтобы разложить файлы
/// по сериям, а «просто в библиотеку» уже умеют загрузка и автоматический inbox.
/// Один файл к разбору. <paramref name="Season"/> и <paramref name="Episode"/> приходят с клиента:
/// там пользователь видит распознанные номера и может поправить их регуляркой или ручным сезоном,
/// и сохраниться должно ровно то, что он видел. Не заданы — сервер разберёт имя сам.
/// </summary>
public sealed record ImportManualInboxCommand(IReadOnlyList<string> RelativePaths, Guid ShowId)
public sealed record ManualImportItem(string RelativePath, int? Season, int? Episode);
/// <summary>
/// Забирает выбранные файлы из <c>manual/</c> в шоу. Файлы уходят из каталога, как и из обычного
/// <c>inbox/</c>, а рядом лежавшие спутники (субтитры, nfo, обложки) удаляются, чтобы каталог
/// не зарастал мусором. Шоу обязательно: ручной разбор затевается ровно ради того, чтобы разложить
/// файлы по сериям, а «просто в библиотеку» уже умеют загрузка и автоматический inbox.
/// </summary>
public sealed record ImportManualInboxCommand(IReadOnlyList<ManualImportItem> Items, Guid ShowId)
: ICommand<Result<ImportManualInboxResultDto>>;
/// <summary>Итог разбора: что взяли и что не смогли — по файлам, а не одной ошибкой на всё.</summary>
@@ -25,8 +33,15 @@ public sealed class ImportManualInboxCommandValidator
{
public ImportManualInboxCommandValidator()
{
RuleFor(x => x.RelativePaths).NotEmpty();
RuleFor(x => x.RelativePaths.Count).LessThanOrEqualTo(200);
RuleFor(x => x.Items).NotEmpty();
RuleFor(x => x.Items.Count).LessThanOrEqualTo(500);
RuleFor(x => x.ShowId).NotEmpty();
RuleForEach(x => x.Items)
.Must(i => i.Season is null or (> 0 and <= 99))
.WithMessage("Сезон вне допустимого диапазона.");
RuleForEach(x => x.Items)
.Must(i => i.Episode is null or (> 0 and <= 999))
.WithMessage("Номер серии вне допустимого диапазона.");
}
}
@@ -33,18 +33,20 @@ public sealed class ImportManualInboxCommandHandler(
// Файлы разбираются по одному и независимо: один неподдерживаемый контейнер не должен
// отменять весь пакет — в ручном разборе это как раз частый случай.
foreach (var relativePath in command.RelativePaths.Distinct(StringComparer.Ordinal))
foreach (var item in command.Items.DistinctBy(i => i.RelativePath, StringComparer.Ordinal))
{
if (!available.TryGetValue(relativePath, out var file))
if (!available.TryGetValue(item.RelativePath, out var file))
{
failed.Add(new ImportFailureDto(relativePath, MediaErrors.SourceNotFound.Message));
failed.Add(
new ImportFailureDto(item.RelativePath, MediaErrors.SourceNotFound.Message)
);
continue;
}
if (!MediaFormats.IsAllowed(file.Name))
{
failed.Add(
new ImportFailureDto(relativePath, MediaErrors.UnsupportedFormat.Message)
new ImportFailureDto(item.RelativePath, MediaErrors.UnsupportedFormat.Message)
);
continue;
}
@@ -52,7 +54,10 @@ public sealed class ImportManualInboxCommandHandler(
if (!show.CanAddEpisode)
{
failed.Add(
new ImportFailureDto(relativePath, ShowErrors.SingleAlreadyHasEpisode.Message)
new ImportFailureDto(
item.RelativePath,
ShowErrors.SingleAlreadyHasEpisode.Message
)
);
continue;
}
@@ -64,30 +69,54 @@ public sealed class ImportManualInboxCommandHandler(
if (duplicate)
{
failed.Add(
new ImportFailureDto(relativePath, MediaErrors.DuplicateFileName.Message)
new ImportFailureDto(item.RelativePath, MediaErrors.DuplicateFileName.Message)
);
continue;
}
var assetId = await RegisterAsync(file, relativePath, cancellationToken);
var assetId = await RegisterAsync(file, item.RelativePath, cancellationToken);
if (assetId is null)
{
failed.Add(new ImportFailureDto(relativePath, MediaErrors.SourceNotFound.Message));
failed.Add(
new ImportFailureDto(item.RelativePath, MediaErrors.SourceNotFound.Message)
);
continue;
}
// Номера серии — из имени файла, тем же разбором, что и при обычном добавлении серии.
var episode = show.AddEpisode(assetId.Value);
if (EpisodeName.Parse(file.Name) is { } parsed)
episode.SetNumbers(parsed.Season, parsed.Episode);
SetNumbers(episode, item, file.Name);
queue.Enqueue(assetId.Value);
imported++;
// Спутники (субтитры, nfo, обложка) без основного файла — мусор, за которым потом
// никто не вернётся. Удаляем сразу после успешного переноса, а не «когда-нибудь».
await storage.CleanupManualLeftoversAsync(item.RelativePath, cancellationToken);
}
return Result.Success(new ImportManualInboxResultDto(imported, failed));
}
/// <summary>
/// Номера серии: сначала то, что пришло с клиента (там пользователь видел разбор и мог поправить
/// его регуляркой или ручным сезоном), иначе — разбор имени на сервере.
/// </summary>
private static void SetNumbers(
Domain.Library.ShowEpisode episode,
ManualImportItem item,
string fileName
)
{
if (item.Episode is { } episodeNumber)
{
episode.SetNumbers(item.Season ?? 1, episodeNumber);
return;
}
if (EpisodeName.Parse(fileName) is { } parsed)
episode.SetNumbers(item.Season ?? parsed.Season, parsed.Episode);
}
/// <summary>
/// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при обычной загрузке:
/// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы
@@ -12,10 +12,10 @@ public sealed record ListManualInboxQuery : IQuery<ManualInboxListDto>;
public sealed record ManualInboxFileDto(
/// <summary>Путь относительно manual/ — он же идентификатор файла в команде импорта.</summary>
string RelativePath,
/// <summary>Каталог внутри manual/ («» — корень): по нему список группируется в UI.</summary>
string Folder,
string Name,
long SizeBytes,
/// <summary>Поддерживается ли контейнер. Неподдерживаемые показываем, чтобы не гадать, куда делся файл.</summary>
bool IsSupported,
/// <summary>Файл с таким именем уже есть в библиотеке — импорт отклонит его как дубликат.</summary>
bool AlreadyImported
);
@@ -16,8 +16,14 @@ public sealed class ListManualInboxQueryHandler(IAppDbContext dbContext, IMediaS
CancellationToken cancellationToken
)
{
// Просим на один больше лимита — так видно, что каталог не поместился целиком.
var files = storage.ListManualInbox(MaxFiles + 1);
// Субтитры, nfo и обложки в списке не нужны: выбрать их всё равно нельзя, а рядом с сериями
// они превращают список в кашу. Спутников по имени импорт подчистит сам.
var files = storage
.ListManualInbox(int.MaxValue)
.Where(f => MediaFormats.IsAllowed(f.Name))
.Take(MaxFiles + 1)
.ToList();
var truncated = files.Count > MaxFiles;
var page = truncated ? files.Take(MaxFiles).ToList() : files;
if (page.Count == 0)
@@ -35,13 +41,20 @@ public sealed class ListManualInboxQueryHandler(IAppDbContext dbContext, IMediaS
return new ManualInboxListDto(
page.Select(f => new ManualInboxFileDto(
f.RelativePath,
Folder(f.RelativePath),
f.Name,
f.SizeBytes,
MediaFormats.IsAllowed(f.Name),
takenSet.Contains(f.Name)
))
.ToList(),
truncated
);
}
/// <summary>Каталог файла внутри manual/ — пустая строка для файлов в корне.</summary>
private static string Folder(string relativePath)
{
var slash = relativePath.LastIndexOf('/');
return slash < 0 ? string.Empty : relativePath[..slash];
}
}
@@ -1,4 +1,5 @@
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
@@ -71,6 +72,58 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
.ToList();
}
public Task CleanupManualLeftoversAsync(
string relativePath,
CancellationToken cancellationToken
)
{
var path = paths.ManualPath(relativePath);
var directory = Path.GetDirectoryName(path);
if (directory is null || !Directory.Exists(directory))
return Task.CompletedTask;
// Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки:
// так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно —
// «Серия.Extended.mkv» это не мусор, а другой материал.
// Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается
// «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным.
var prefix = Path.GetFileNameWithoutExtension(path) + ".";
foreach (var sibling in Directory.EnumerateFiles(directory))
{
var name = Path.GetFileName(sibling);
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
continue;
if (MediaFormats.IsAllowed(name))
continue;
try
{
File.Delete(sibling);
}
catch (IOException)
{
// Файл занят или уже удалён — не повод валить импорт целиком.
}
}
// Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда.
if (
!string.Equals(directory, paths.ManualDir, StringComparison.Ordinal)
&& !Directory.EnumerateFileSystemEntries(directory).Any()
)
{
try
{
Directory.Delete(directory);
}
catch (IOException)
{
// Каталог занят — оставим как есть.
}
}
return Task.CompletedTask;
}
public Task PromoteToOriginalAsync(
MediaSource source,
string sourceToken,
@@ -42,7 +42,10 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand([$"Сериал/{first}", $"Сериал/{second}"], showId),
new ImportManualInboxCommand(
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
showId
),
default
);
@@ -111,7 +114,10 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand([good, "readme.txt", "ушёл.mkv"], showId),
new ImportManualInboxCommand(
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
showId
),
default
);
@@ -123,6 +129,44 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
}
[SkippableFact]
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId), default);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync();
await using var verify = fixture.CreateContext();
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
Assert.Equal(4, episode.Season);
Assert.Equal(12, episode.Episode);
// И спутники рядом с забранным файлом убираются.
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
}
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
private static async Task<Guid> SeedShowAsync(AppDbContext db)
{
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
@@ -0,0 +1,160 @@
using Microsoft.Extensions.Options;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Media;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Файловая часть ручного разбора: перечисление, перенос и подчистка спутников. Работает с реальной
/// ФС во временном каталоге — код удаляет файлы, и проверять его на моках бессмысленно.
/// </summary>
public sealed class ManualInboxStorageTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"telewave-tests-" + Guid.NewGuid().ToString("N")[..8]
);
private readonly FileSystemMediaStorage _storage;
private readonly MediaPathResolver _paths;
public ManualInboxStorageTests()
{
_paths = new MediaPathResolver(Options.Create(new StorageOptions { RootPath = _root }));
_paths.EnsureDirectories();
_storage = new FileSystemMediaStorage(_paths);
}
[Fact]
public void ListManualInbox_WalksSubdirectories_AndReturnsRelativePaths()
{
Write("Сериал/s01e01.mkv");
Write("Сериал/s01e02.mkv");
Write("одиночный.mp4");
var files = _storage.ListManualInbox(int.MaxValue);
Assert.Equal(3, files.Count);
// Разделитель нормализован: путь уезжает в URL и возвращается строкой запроса.
Assert.Contains(files, f => f.RelativePath == "Сериал/s01e01.mkv");
Assert.Contains(files, f => f.RelativePath == "одиночный.mp4");
Assert.All(files, f => Assert.True(f.SizeBytes > 0));
}
[Fact]
public async Task PromoteToOriginal_MovesFileOutOfManual()
{
Write("Сериал/s01e01.mkv");
var assetId = Guid.NewGuid();
await _storage.PromoteToOriginalAsync(
MediaSource.ManualInbox,
"Сериал/s01e01.mkv",
assetId,
".mkv",
default
);
Assert.False(File.Exists(Path.Combine(_paths.ManualDir, "Сериал", "s01e01.mkv")));
Assert.True(File.Exists(_paths.OriginalPath(assetId, ".mkv")));
}
[Fact]
public async Task Cleanup_RemovesSiblings_ButKeepsOtherVideos()
{
Write("Сериал/s01e01.mkv");
Write("Сериал/s01e01.srt");
Write("Сериал/s01e01.ru.srt");
Write("Сериал/s01e01.nfo");
Write("Сериал/s01e01.Extended.mkv");
Write("Сериал/s01e02.mkv");
Write("Сериал/s01e02.srt");
// Так делает импорт: сначала файл уходит в originals/, потом подчищаются спутники.
await _storage.PromoteToOriginalAsync(
MediaSource.ManualInbox,
"Сериал/s01e01.mkv",
Guid.NewGuid(),
".mkv",
default
);
await _storage.CleanupManualLeftoversAsync("Сериал/s01e01.mkv", default);
var left = Directory
.EnumerateFiles(Path.Combine(_paths.ManualDir, "Сериал"))
.Select(Path.GetFileName)
.ToList();
// Субтитры и nfo забранной серии ушли…
Assert.DoesNotContain("s01e01.srt", left);
Assert.DoesNotContain("s01e01.ru.srt", left);
Assert.DoesNotContain("s01e01.nfo", left);
// …а другой материал и спутники соседних серий остались.
Assert.Contains("s01e01.Extended.mkv", left);
Assert.Contains("s01e02.mkv", left);
Assert.Contains("s01e02.srt", left);
}
[Fact]
public async Task Cleanup_RemovesEmptiedFolder_ButNotManualRoot()
{
Write("Сериал/один.mkv");
Write("Сериал/один.srt");
Write("в-корне.mkv");
await _storage.PromoteToOriginalAsync(
MediaSource.ManualInbox,
"Сериал/один.mkv",
Guid.NewGuid(),
".mkv",
default
);
await _storage.CleanupManualLeftoversAsync("Сериал/один.mkv", default);
Assert.False(Directory.Exists(Path.Combine(_paths.ManualDir, "Сериал")));
// Файл в корне забираем — сам корень manual/ остаться обязан.
await _storage.PromoteToOriginalAsync(
MediaSource.ManualInbox,
"в-корне.mkv",
Guid.NewGuid(),
".mkv",
default
);
await _storage.CleanupManualLeftoversAsync("в-корне.mkv", default);
Assert.True(Directory.Exists(_paths.ManualDir));
}
[Fact]
public async Task Cleanup_KeepsFolderThatStillHasFiles()
{
Write("Сериал/s01e01.mkv");
Write("Сериал/s01e02.mkv");
await _storage.PromoteToOriginalAsync(
MediaSource.ManualInbox,
"Сериал/s01e01.mkv",
Guid.NewGuid(),
".mkv",
default
);
await _storage.CleanupManualLeftoversAsync("Сериал/s01e01.mkv", default);
Assert.True(File.Exists(Path.Combine(_paths.ManualDir, "Сериал", "s01e02.mkv")));
}
private void Write(string relativePath)
{
var full = Path.Combine(_paths.ManualDir, relativePath.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, "x");
}
public void Dispose()
{
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
}