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
@@ -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);
}
}