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.
This commit is contained in:
@@ -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("Номер серии вне допустимого диапазона.");
|
||||
}
|
||||
}
|
||||
|
||||
+40
-11
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user