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:
@@ -126,7 +126,12 @@ jobs:
|
|||||||
/d:sonar.token="$SONAR_TOKEN" \
|
/d:sonar.token="$SONAR_TOKEN" \
|
||||||
/d:sonar.cs.opencover.reportsPaths="**/coverage/**/coverage.opencover.xml" \
|
/d:sonar.cs.opencover.reportsPaths="**/coverage/**/coverage.opencover.xml" \
|
||||||
/d:sonar.exclusions="frontend/node_modules/**,frontend/dist/**,frontend/src/routeTree.gen.ts,backend/src/TeleWave.Infrastructure/Migrations/**" \
|
/d:sonar.exclusions="frontend/node_modules/**,frontend/dist/**,frontend/src/routeTree.gen.ts,backend/src/TeleWave.Infrastructure/Migrations/**" \
|
||||||
/d:sonar.coverage.exclusions="backend/src/TeleWave.Infrastructure/Migrations/**,frontend/**"
|
/d:sonar.coverage.exclusions="backend/src/TeleWave.Infrastructure/Migrations/**,frontend/**" \
|
||||||
|
/d:sonar.cpd.exclusions="frontend/src/shared/lib/locales/**"
|
||||||
|
|
||||||
|
# Локали исключены из поиска дублей намеренно: ru.ts и en.ts обязаны совпадать ключ в ключ —
|
||||||
|
# это не копипаста, а единственный способ не потерять перевод, и «устранение» такого дубля
|
||||||
|
# означало бы отказ от второго языка.
|
||||||
|
|
||||||
# Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true —
|
# Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true —
|
||||||
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
|
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
|
||||||
|
|||||||
@@ -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.ManualInbox;
|
||||||
using TeleWave.Application.Media.MovieImport;
|
using TeleWave.Application.Media.MovieImport;
|
||||||
using TeleWave.Application.Metadata;
|
using TeleWave.Application.Metadata;
|
||||||
using TeleWave.Application.Programming.Groups;
|
using TeleWave.Application.Programming.Groups;
|
||||||
@@ -42,6 +43,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<GenreMatcher>();
|
services.AddScoped<GenreMatcher>();
|
||||||
services.AddScoped<ShowMetadataApplier>();
|
services.AddScoped<ShowMetadataApplier>();
|
||||||
services.AddScoped<MovieMatcher>();
|
services.AddScoped<MovieMatcher>();
|
||||||
|
services.AddScoped<ManualInboxTaker>();
|
||||||
services.AddScoped<CollectionSuggestionBuilder>();
|
services.AddScoped<CollectionSuggestionBuilder>();
|
||||||
services.AddScoped<GroupElementResolver>();
|
services.AddScoped<GroupElementResolver>();
|
||||||
services.AddScoped<GroupFilterMatcher>();
|
services.AddScoped<GroupFilterMatcher>();
|
||||||
|
|||||||
+9
-79
@@ -9,7 +9,7 @@ namespace TeleWave.Application.Media.ManualInbox;
|
|||||||
|
|
||||||
public sealed class ImportManualInboxCommandHandler(
|
public sealed class ImportManualInboxCommandHandler(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IMediaStorage storage,
|
ManualInboxTaker taker,
|
||||||
IMediaProcessingQueue queue
|
IMediaProcessingQueue queue
|
||||||
) : ICommandHandler<ImportManualInboxCommand, Result<ImportManualInboxResultDto>>
|
) : ICommandHandler<ImportManualInboxCommand, Result<ImportManualInboxResultDto>>
|
||||||
{
|
{
|
||||||
@@ -24,9 +24,7 @@ public sealed class ImportManualInboxCommandHandler(
|
|||||||
if (show is null)
|
if (show is null)
|
||||||
return Result.Failure<ImportManualInboxResultDto>(ShowErrors.NotFound);
|
return Result.Failure<ImportManualInboxResultDto>(ShowErrors.NotFound);
|
||||||
|
|
||||||
var available = storage
|
var available = taker.List();
|
||||||
.ListManualInbox(int.MaxValue)
|
|
||||||
.ToDictionary(f => f.RelativePath, StringComparer.Ordinal);
|
|
||||||
|
|
||||||
var failed = new List<ImportFailureDto>();
|
var failed = new List<ImportFailureDto>();
|
||||||
var imported = 0;
|
var imported = 0;
|
||||||
@@ -35,22 +33,6 @@ public sealed class ImportManualInboxCommandHandler(
|
|||||||
// отменять весь пакет — в ручном разборе это как раз частый случай.
|
// отменять весь пакет — в ручном разборе это как раз частый случай.
|
||||||
foreach (var item in command.Items.DistinctBy(i => i.RelativePath, StringComparer.Ordinal))
|
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)
|
if (!show.CanAddEpisode)
|
||||||
{
|
{
|
||||||
failed.Add(
|
failed.Add(
|
||||||
@@ -62,36 +44,20 @@ public sealed class ImportManualInboxCommandHandler(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var duplicate = await dbContext.MediaAssets.AnyAsync(
|
var taken = await taker.TakeAsync(available, item.RelativePath, cancellationToken);
|
||||||
a => a.OriginalFileName == file.Name && a.Status != MediaAssetStatus.Failed,
|
if (!taken.IsSuccess)
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
if (duplicate)
|
|
||||||
{
|
{
|
||||||
failed.Add(
|
failed.Add(new ImportFailureDto(item.RelativePath, taken.Error.Message));
|
||||||
new ImportFailureDto(item.RelativePath, MediaErrors.DuplicateFileName.Message)
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var assetId = await RegisterAsync(file, item.RelativePath, cancellationToken);
|
var episode = show.AddEpisode(taken.Value);
|
||||||
if (assetId is null)
|
SetNumbers(episode, item, available[item.RelativePath].Name);
|
||||||
{
|
|
||||||
failed.Add(
|
|
||||||
new ImportFailureDto(item.RelativePath, MediaErrors.SourceNotFound.Message)
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var episode = show.AddEpisode(assetId.Value);
|
queue.Enqueue(taken.Value);
|
||||||
SetNumbers(episode, item, file.Name);
|
|
||||||
|
|
||||||
queue.Enqueue(assetId.Value);
|
|
||||||
imported++;
|
imported++;
|
||||||
|
|
||||||
// Спутники (субтитры, nfo, обложка) без основного файла — мусор, за которым потом
|
await taker.CleanupAsync(item.RelativePath, cancellationToken);
|
||||||
// никто не вернётся. Удаляем сразу после успешного переноса, а не «когда-нибудь».
|
|
||||||
await storage.CleanupManualLeftoversAsync(item.RelativePath, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success(new ImportManualInboxResultDto(imported, failed));
|
return Result.Success(new ImportManualInboxResultDto(imported, failed));
|
||||||
@@ -116,40 +82,4 @@ public sealed class ImportManualInboxCommandHandler(
|
|||||||
if (EpisodeName.Parse(fileName) is { } parsed)
|
if (EpisodeName.Parse(fileName) is { } parsed)
|
||||||
episode.SetNumbers(item.Season ?? parsed.Season, parsed.Episode);
|
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 Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Media.ManualInbox;
|
||||||
using TeleWave.Application.Metadata;
|
using TeleWave.Application.Metadata;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Media.MovieImport;
|
namespace TeleWave.Application.Media.MovieImport;
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ namespace TeleWave.Application.Media.MovieImport;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ImportMoviesCommandHandler(
|
public sealed class ImportMoviesCommandHandler(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IMediaStorage storage,
|
ManualInboxTaker taker,
|
||||||
IMediaProcessingQueue queue,
|
IMediaProcessingQueue queue,
|
||||||
ShowMetadataApplier applier
|
ShowMetadataApplier applier
|
||||||
) : ICommandHandler<ImportMoviesCommand, Result<ImportMoviesResultDto>>
|
) : ICommandHandler<ImportMoviesCommand, Result<ImportMoviesResultDto>>
|
||||||
@@ -27,9 +27,7 @@ public sealed class ImportMoviesCommandHandler(
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var available = storage
|
var available = taker.List();
|
||||||
.ListManualInbox(int.MaxValue)
|
|
||||||
.ToDictionary(f => f.RelativePath, StringComparer.Ordinal);
|
|
||||||
|
|
||||||
var failed = new List<MovieImportFailureDto>();
|
var failed = new List<MovieImportFailureDto>();
|
||||||
var created = 0;
|
var created = 0;
|
||||||
@@ -50,7 +48,7 @@ public sealed class ImportMoviesCommandHandler(
|
|||||||
queue.Enqueue(asset.Value.AssetId);
|
queue.Enqueue(asset.Value.AssetId);
|
||||||
|
|
||||||
if (item.RelativePath is { Length: > 0 } path)
|
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)
|
if (item.ExternalId is not { Length: > 0 } externalId)
|
||||||
continue;
|
continue;
|
||||||
@@ -103,105 +101,48 @@ public sealed class ImportMoviesCommandHandler(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (item.AssetId is { } assetId)
|
if (item.AssetId is { } assetId)
|
||||||
{
|
return await UploadedAsync(item, assetId, failed, cancellationToken);
|
||||||
var asset = await dbContext.MediaAssets.FirstOrDefaultAsync(
|
|
||||||
a => a.Id == assetId,
|
var taken = await taker.TakeAsync(
|
||||||
|
available,
|
||||||
|
item.RelativePath ?? string.Empty,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (asset is null)
|
if (taken.IsSuccess)
|
||||||
|
return (taken.Value, true);
|
||||||
|
|
||||||
|
failed.Add(new MovieImportFailureDto(item.Title, taken.Error.Message));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Уже загруженный браузером файл. В очередь он попал ещё при загрузке, поэтому второй раз
|
||||||
|
/// не ставится — иначе один и тот же исходник нарезался бы дважды.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(Guid AssetId, bool Enqueue)?> UploadedAsync(
|
||||||
|
MovieImportItem item,
|
||||||
|
Guid assetId,
|
||||||
|
List<MovieImportFailureDto> failed,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var exists = await dbContext.MediaAssets.AnyAsync(a => a.Id == assetId, cancellationToken);
|
||||||
|
if (!exists)
|
||||||
{
|
{
|
||||||
failed.Add(new MovieImportFailureDto(item.Title, MediaErrors.NotFound.Message));
|
failed.Add(new MovieImportFailureDto(item.Title, MediaErrors.NotFound.Message));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загруженный ассет уже в очереди с момента загрузки — второй раз не ставим.
|
|
||||||
var taken = await dbContext.Shows.AnyAsync(
|
var taken = await dbContext.Shows.AnyAsync(
|
||||||
s => s.Episodes.Any(e => e.MediaAssetId == assetId),
|
s => s.Episodes.Any(e => e.MediaAssetId == assetId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (taken)
|
if (taken)
|
||||||
{
|
{
|
||||||
failed.Add(
|
failed.Add(new MovieImportFailureDto(item.Title, MediaErrors.AlreadyAttached.Message));
|
||||||
new MovieImportFailureDto(item.Title, MediaErrors.AlreadyAttached.Message)
|
|
||||||
);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (assetId, false);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Library.Genres;
|
using TeleWave.Application.Library.Genres;
|
||||||
|
using TeleWave.Application.Media.ManualInbox;
|
||||||
using TeleWave.Application.Media.MovieImport;
|
using TeleWave.Application.Media.MovieImport;
|
||||||
using TeleWave.Application.Metadata;
|
using TeleWave.Application.Metadata;
|
||||||
using TeleWave.Application.Tests.Support;
|
using TeleWave.Application.Tests.Support;
|
||||||
@@ -64,10 +65,12 @@ public class ImportMoviesTests
|
|||||||
new GenreMatcher(db)
|
new GenreMatcher(db)
|
||||||
);
|
);
|
||||||
|
|
||||||
var result = await new ImportMoviesCommandHandler(db, storage, queue, applier).Handle(
|
var result = await new ImportMoviesCommandHandler(
|
||||||
new ImportMoviesCommand(items, Provider),
|
db,
|
||||||
CancellationToken.None
|
new ManualInboxTaker(db, storage),
|
||||||
);
|
queue,
|
||||||
|
applier
|
||||||
|
).Handle(new ImportMoviesCommand(items, Provider), CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
|
|||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var result = await new ImportManualInboxCommandHandler(
|
var result = await new ImportManualInboxCommandHandler(
|
||||||
db,
|
db,
|
||||||
storage,
|
new ManualInboxTaker(db, storage),
|
||||||
Substitute.For<IMediaProcessingQueue>()
|
Substitute.For<IMediaProcessingQueue>()
|
||||||
).Handle(
|
).Handle(
|
||||||
new ImportManualInboxCommand(
|
new ImportManualInboxCommand(
|
||||||
@@ -107,7 +107,7 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
|
|||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var result = await new ImportManualInboxCommandHandler(
|
var result = await new ImportManualInboxCommandHandler(
|
||||||
db,
|
db,
|
||||||
storage,
|
new ManualInboxTaker(db, storage),
|
||||||
Substitute.For<IMediaProcessingQueue>()
|
Substitute.For<IMediaProcessingQueue>()
|
||||||
).Handle(
|
).Handle(
|
||||||
new ImportManualInboxCommand(
|
new ImportManualInboxCommand(
|
||||||
@@ -142,7 +142,7 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
|
|||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var result = await new ImportManualInboxCommandHandler(
|
var result = await new ImportManualInboxCommandHandler(
|
||||||
db,
|
db,
|
||||||
storage,
|
new ManualInboxTaker(db, storage),
|
||||||
Substitute.For<IMediaProcessingQueue>()
|
Substitute.For<IMediaProcessingQueue>()
|
||||||
).Handle(
|
).Handle(
|
||||||
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
|
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля разбора серий: ручной сезон и regex номера. Один компонент на оба разбора — из `manual/`
|
||||||
|
* и при загрузке файлов, — потому что правила у них общие: что показано в предпросмотре, то
|
||||||
|
* и уедет на сервер, и расходиться этим двум местам нельзя.
|
||||||
|
*/
|
||||||
|
export function EpisodeParseFields({
|
||||||
|
season,
|
||||||
|
onSeason,
|
||||||
|
regex,
|
||||||
|
onRegex,
|
||||||
|
regexOk,
|
||||||
|
}: Readonly<{
|
||||||
|
season: string
|
||||||
|
onSeason: (value: string) => void
|
||||||
|
regex: string
|
||||||
|
onRegex: (value: string) => void
|
||||||
|
/** Регулярка разбирается — иначе поле подсвечивается и снизу появляется объяснение. */
|
||||||
|
regexOk: boolean
|
||||||
|
}>) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.media.toShowSeason')}</Label>
|
||||||
|
{/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder={t('admin.media.toShowAuto')}
|
||||||
|
value={season}
|
||||||
|
onChange={(e) => onSeason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.media.toShowRegex')}</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="^(\d+)"
|
||||||
|
value={regex}
|
||||||
|
onChange={(e) => onRegex(e.target.value)}
|
||||||
|
className={!regexOk ? 'border-red-500' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
parseSeasonFromFolder,
|
parseSeasonFromFolder,
|
||||||
} from './episode-parse'
|
} from './episode-parse'
|
||||||
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
|
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
|
||||||
|
import { EpisodeParseFields } from './EpisodeParseFields'
|
||||||
import { matchShowByName } from './match-show'
|
import { matchShowByName } from './match-show'
|
||||||
|
|
||||||
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
||||||
@@ -250,27 +251,14 @@ export function ManualInboxPanel({ onClose }: Readonly<{ onClose: () => void }>)
|
|||||||
<Label>{t('common.search')}</Label>
|
<Label>{t('common.search')}</Label>
|
||||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<EpisodeParseFields
|
||||||
<Label>{t('admin.media.toShowSeason')}</Label>
|
season={seasonStr}
|
||||||
{/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */}
|
onSeason={setSeasonStr}
|
||||||
<Input
|
regex={regexStr}
|
||||||
type="number"
|
onRegex={setRegexStr}
|
||||||
min={0}
|
regexOk={regexOk}
|
||||||
placeholder={t('admin.media.toShowAuto')}
|
|
||||||
value={seasonStr}
|
|
||||||
onChange={(e) => setSeasonStr(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.media.toShowRegex')}</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="^(\d+)"
|
|
||||||
value={regexStr}
|
|
||||||
onChange={(e) => setRegexStr(e.target.value)}
|
|
||||||
className={!regexOk ? 'border-red-500' : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>}
|
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>}
|
||||||
|
|
||||||
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import { qk } from '@/shared/api/query-keys'
|
|||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { DialogFooter } from '@/shared/ui/dialog'
|
import { DialogFooter } from '@/shared/ui/dialog'
|
||||||
import { Input } from '@/shared/ui/input'
|
|
||||||
import { Label } from '@/shared/ui/label'
|
|
||||||
import { listShows } from '@/features/admin/shows/api'
|
import { listShows } from '@/features/admin/shows/api'
|
||||||
import { ShowPicker } from '@/features/admin/shows/ShowPicker'
|
import { ShowPicker } from '@/features/admin/shows/ShowPicker'
|
||||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||||
|
import { EpisodeParseFields } from './EpisodeParseFields'
|
||||||
import { matchShowByName } from './match-show'
|
import { matchShowByName } from './match-show'
|
||||||
import { useUploadStore } from './upload-store'
|
import { useUploadStore } from './upload-store'
|
||||||
|
|
||||||
@@ -101,26 +100,14 @@ export function UploadToShowPanel({ onClose }: Readonly<{ onClose: () => void }>
|
|||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<EpisodeParseFields
|
||||||
<Label>{t('admin.media.toShowSeason')}</Label>
|
season={seasonStr}
|
||||||
<Input
|
onSeason={setSeasonStr}
|
||||||
type="number"
|
regex={regexStr}
|
||||||
min={1}
|
onRegex={setRegexStr}
|
||||||
placeholder={t('admin.media.toShowAuto')}
|
regexOk={regexOk}
|
||||||
value={seasonStr}
|
|
||||||
onChange={(e) => setSeasonStr(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.media.toShowRegex')}</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="^(\d+)"
|
|
||||||
value={regexStr}
|
|
||||||
onChange={(e) => setRegexStr(e.target.value)}
|
|
||||||
className={!regexOk ? 'border-red-500' : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('admin.media.toShowHint')}
|
{t('admin.media.toShowHint')}
|
||||||
{!regexOk && (
|
{!regexOk && (
|
||||||
|
|||||||
Reference in New Issue
Block a user