Refactor media import handlers to utilize ManualInboxTaker for improved functionality
Updated the ImportManualInboxCommandHandler and ImportMoviesCommandHandler to replace direct storage interactions with the ManualInboxTaker, streamlining the import process. This change enhances code clarity and maintains consistency across media import functionalities. Additionally, refactored related tests to accommodate the new handler structure, ensuring robust testing of the import logic.
This commit is contained in:
@@ -7,6 +7,7 @@ using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Common.Behaviors;
|
||||
using TeleWave.Application.Library.Collections.Suggest;
|
||||
using TeleWave.Application.Library.Genres;
|
||||
using TeleWave.Application.Media.ManualInbox;
|
||||
using TeleWave.Application.Media.MovieImport;
|
||||
using TeleWave.Application.Metadata;
|
||||
using TeleWave.Application.Programming.Groups;
|
||||
@@ -42,6 +43,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<GenreMatcher>();
|
||||
services.AddScoped<ShowMetadataApplier>();
|
||||
services.AddScoped<MovieMatcher>();
|
||||
services.AddScoped<ManualInboxTaker>();
|
||||
services.AddScoped<CollectionSuggestionBuilder>();
|
||||
services.AddScoped<GroupElementResolver>();
|
||||
services.AddScoped<GroupFilterMatcher>();
|
||||
|
||||
+9
-79
@@ -9,7 +9,7 @@ namespace TeleWave.Application.Media.ManualInbox;
|
||||
|
||||
public sealed class ImportManualInboxCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IMediaStorage storage,
|
||||
ManualInboxTaker taker,
|
||||
IMediaProcessingQueue queue
|
||||
) : ICommandHandler<ImportManualInboxCommand, Result<ImportManualInboxResultDto>>
|
||||
{
|
||||
@@ -24,9 +24,7 @@ public sealed class ImportManualInboxCommandHandler(
|
||||
if (show is null)
|
||||
return Result.Failure<ImportManualInboxResultDto>(ShowErrors.NotFound);
|
||||
|
||||
var available = storage
|
||||
.ListManualInbox(int.MaxValue)
|
||||
.ToDictionary(f => f.RelativePath, StringComparer.Ordinal);
|
||||
var available = taker.List();
|
||||
|
||||
var failed = new List<ImportFailureDto>();
|
||||
var imported = 0;
|
||||
@@ -35,22 +33,6 @@ public sealed class ImportManualInboxCommandHandler(
|
||||
// отменять весь пакет — в ручном разборе это как раз частый случай.
|
||||
foreach (var item in command.Items.DistinctBy(i => i.RelativePath, StringComparer.Ordinal))
|
||||
{
|
||||
if (!available.TryGetValue(item.RelativePath, out var file))
|
||||
{
|
||||
failed.Add(
|
||||
new ImportFailureDto(item.RelativePath, MediaErrors.SourceNotFound.Message)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MediaFormats.IsAllowed(file.Name))
|
||||
{
|
||||
failed.Add(
|
||||
new ImportFailureDto(item.RelativePath, MediaErrors.UnsupportedFormat.Message)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!show.CanAddEpisode)
|
||||
{
|
||||
failed.Add(
|
||||
@@ -62,36 +44,20 @@ public sealed class ImportManualInboxCommandHandler(
|
||||
continue;
|
||||
}
|
||||
|
||||
var duplicate = await dbContext.MediaAssets.AnyAsync(
|
||||
a => a.OriginalFileName == file.Name && a.Status != MediaAssetStatus.Failed,
|
||||
cancellationToken
|
||||
);
|
||||
if (duplicate)
|
||||
var taken = await taker.TakeAsync(available, item.RelativePath, cancellationToken);
|
||||
if (!taken.IsSuccess)
|
||||
{
|
||||
failed.Add(
|
||||
new ImportFailureDto(item.RelativePath, MediaErrors.DuplicateFileName.Message)
|
||||
);
|
||||
failed.Add(new ImportFailureDto(item.RelativePath, taken.Error.Message));
|
||||
continue;
|
||||
}
|
||||
|
||||
var assetId = await RegisterAsync(file, item.RelativePath, cancellationToken);
|
||||
if (assetId is null)
|
||||
{
|
||||
failed.Add(
|
||||
new ImportFailureDto(item.RelativePath, MediaErrors.SourceNotFound.Message)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
var episode = show.AddEpisode(taken.Value);
|
||||
SetNumbers(episode, item, available[item.RelativePath].Name);
|
||||
|
||||
var episode = show.AddEpisode(assetId.Value);
|
||||
SetNumbers(episode, item, file.Name);
|
||||
|
||||
queue.Enqueue(assetId.Value);
|
||||
queue.Enqueue(taken.Value);
|
||||
imported++;
|
||||
|
||||
// Спутники (субтитры, nfo, обложка) без основного файла — мусор, за которым потом
|
||||
// никто не вернётся. Удаляем сразу после успешного переноса, а не «когда-нибудь».
|
||||
await storage.CleanupManualLeftoversAsync(item.RelativePath, cancellationToken);
|
||||
await taker.CleanupAsync(item.RelativePath, cancellationToken);
|
||||
}
|
||||
|
||||
return Result.Success(new ImportManualInboxResultDto(imported, failed));
|
||||
@@ -116,40 +82,4 @@ public sealed class ImportManualInboxCommandHandler(
|
||||
if (EpisodeName.Parse(fileName) is { } parsed)
|
||||
episode.SetNumbers(item.Season ?? parsed.Season, parsed.Episode);
|
||||
}
|
||||
|
||||
/// <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,79 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Media.ManualInbox;
|
||||
|
||||
/// <summary>
|
||||
/// Забирает файл из <c>manual/</c> в библиотеку: проверки, регистрация ассета и перенос в
|
||||
/// originals/. Общее для обоих разборов — серии в шоу и фильмы пачкой: правила «что можно взять»
|
||||
/// и порядок «сначала строка в БД, потом файл» обязаны быть одни, иначе один разбор однажды
|
||||
/// оставит на диске то, что другой убирает.
|
||||
/// </summary>
|
||||
public sealed class ManualInboxTaker(IAppDbContext dbContext, IMediaStorage storage)
|
||||
{
|
||||
/// <summary>Что лежит в каталоге, по относительному пути — по нему файл и опознаётся.</summary>
|
||||
public IReadOnlyDictionary<string, IMediaStorage.ManualInboxFile> List() =>
|
||||
storage
|
||||
.ListManualInbox(int.MaxValue)
|
||||
.ToDictionary(f => f.RelativePath, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Берёт файл: проверяет формат и дубль по имени, регистрирует ассет и переносит его в
|
||||
/// originals/. Ошибка возвращается значением, а не исключением: в пакетном разборе одна
|
||||
/// негодная строка не должна отменять остальные.
|
||||
/// </summary>
|
||||
public async Task<Result<Guid>> TakeAsync(
|
||||
IReadOnlyDictionary<string, IMediaStorage.ManualInboxFile> available,
|
||||
string relativePath,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!available.TryGetValue(relativePath, out var file))
|
||||
return Result.Failure<Guid>(MediaErrors.SourceNotFound);
|
||||
|
||||
if (!MediaFormats.IsAllowed(file.Name))
|
||||
return Result.Failure<Guid>(MediaErrors.UnsupportedFormat);
|
||||
|
||||
var duplicate = await dbContext.MediaAssets.AnyAsync(
|
||||
a => a.OriginalFileName == file.Name && a.Status != MediaAssetStatus.Failed,
|
||||
cancellationToken
|
||||
);
|
||||
if (duplicate)
|
||||
return Result.Failure<Guid>(MediaErrors.DuplicateFileName);
|
||||
|
||||
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 Result.Failure<Guid>(MediaErrors.SourceNotFound);
|
||||
}
|
||||
|
||||
return Result.Success(asset.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Убирает то, что осталось в каталоге от забранного файла: спутники (субтитры, nfo, обложка)
|
||||
/// и опустевшую папку. Без основного файла они мусор, за которым никто не вернётся.
|
||||
/// </summary>
|
||||
public Task CleanupAsync(string relativePath, CancellationToken cancellationToken) =>
|
||||
storage.CleanupManualLeftoversAsync(relativePath, cancellationToken);
|
||||
}
|
||||
@@ -2,9 +2,9 @@ using LiteCqrs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Common.Models;
|
||||
using TeleWave.Application.Media.ManualInbox;
|
||||
using TeleWave.Application.Metadata;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Media.MovieImport;
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace TeleWave.Application.Media.MovieImport;
|
||||
/// </summary>
|
||||
public sealed class ImportMoviesCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IMediaStorage storage,
|
||||
ManualInboxTaker taker,
|
||||
IMediaProcessingQueue queue,
|
||||
ShowMetadataApplier applier
|
||||
) : ICommandHandler<ImportMoviesCommand, Result<ImportMoviesResultDto>>
|
||||
@@ -27,9 +27,7 @@ public sealed class ImportMoviesCommandHandler(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var available = storage
|
||||
.ListManualInbox(int.MaxValue)
|
||||
.ToDictionary(f => f.RelativePath, StringComparer.Ordinal);
|
||||
var available = taker.List();
|
||||
|
||||
var failed = new List<MovieImportFailureDto>();
|
||||
var created = 0;
|
||||
@@ -50,7 +48,7 @@ public sealed class ImportMoviesCommandHandler(
|
||||
queue.Enqueue(asset.Value.AssetId);
|
||||
|
||||
if (item.RelativePath is { Length: > 0 } path)
|
||||
await storage.CleanupManualLeftoversAsync(path, cancellationToken);
|
||||
await taker.CleanupAsync(path, cancellationToken);
|
||||
|
||||
if (item.ExternalId is not { Length: > 0 } externalId)
|
||||
continue;
|
||||
@@ -103,105 +101,48 @@ public sealed class ImportMoviesCommandHandler(
|
||||
)
|
||||
{
|
||||
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;
|
||||
}
|
||||
return await UploadedAsync(item, assetId, failed, cancellationToken);
|
||||
|
||||
// Загруженный ассет уже в очереди с момента загрузки — второй раз не ставим.
|
||||
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,
|
||||
var taken = await taker.TakeAsync(
|
||||
available,
|
||||
item.RelativePath ?? string.Empty,
|
||||
cancellationToken
|
||||
);
|
||||
if (duplicate)
|
||||
{
|
||||
failed.Add(
|
||||
new MovieImportFailureDto(item.Title, MediaErrors.DuplicateFileName.Message)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (taken.IsSuccess)
|
||||
return (taken.Value, true);
|
||||
|
||||
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);
|
||||
failed.Add(new MovieImportFailureDto(item.Title, taken.Error.Message));
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при ручном разборе:
|
||||
/// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы
|
||||
/// не осталось записи без исходника.
|
||||
/// Уже загруженный браузером файл. В очередь он попал ещё при загрузке, поэтому второй раз
|
||||
/// не ставится — иначе один и тот же исходник нарезался бы дважды.
|
||||
/// </summary>
|
||||
private async Task<Guid?> RegisterAsync(
|
||||
IMediaStorage.ManualInboxFile file,
|
||||
string relativePath,
|
||||
private async Task<(Guid AssetId, bool Enqueue)?> UploadedAsync(
|
||||
MovieImportItem item,
|
||||
Guid assetId,
|
||||
List<MovieImportFailureDto> failed,
|
||||
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
|
||||
var exists = await dbContext.MediaAssets.AnyAsync(a => a.Id == assetId, cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
await storage.PromoteToOriginalAsync(
|
||||
MediaSource.ManualInbox,
|
||||
relativePath,
|
||||
asset.Id,
|
||||
extension,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
dbContext.MediaAssets.Remove(asset);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
failed.Add(new MovieImportFailureDto(item.Title, MediaErrors.NotFound.Message));
|
||||
return null;
|
||||
}
|
||||
|
||||
return asset.Id;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user