Refactor media import handlers to utilize ManualInboxTaker for improved functionality
ci / build-backend (push) Successful in 1m36s
ci / build-frontend (push) Successful in 57s
ci / tests (push) Successful in 1m36s
ci / sonar (push) Successful in 4m40s

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:
Leonid Pershin
2026-07-28 10:46:21 +03:00
parent 287ed5aa12
commit 6ca629c13a
10 changed files with 204 additions and 219 deletions
+6 -1
View File
@@ -126,7 +126,12 @@ jobs:
/d:sonar.token="$SONAR_TOKEN" \
/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.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 —
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
@@ -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,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);
}
}
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Metadata;
using TeleWave.Application.Tests.Support;
@@ -64,10 +65,12 @@ public class ImportMoviesTests
new GenreMatcher(db)
);
var result = await new ImportMoviesCommandHandler(db, storage, queue, applier).Handle(
new ImportMoviesCommand(items, Provider),
CancellationToken.None
);
var result = await new ImportMoviesCommandHandler(
db,
new ManualInboxTaker(db, storage),
queue,
applier
).Handle(new ImportMoviesCommand(items, Provider), CancellationToken.None);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
@@ -37,7 +37,7 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
new ManualInboxTaker(db, storage),
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand(
@@ -107,7 +107,7 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
new ManualInboxTaker(db, storage),
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand(
@@ -142,7 +142,7 @@ public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
new ManualInboxTaker(db, storage),
Substitute.For<IMediaProcessingQueue>()
).Handle(
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,
} from './episode-parse'
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
import { EpisodeParseFields } from './EpisodeParseFields'
import { matchShowByName } from './match-show'
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
@@ -250,26 +251,13 @@ export function ManualInboxPanel({ onClose }: Readonly<{ onClose: () => void }>)
<Label>{t('common.search')}</Label>
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<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={seasonStr}
onChange={(e) => setSeasonStr(e.target.value)}
/>
</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>
<EpisodeParseFields
season={seasonStr}
onSeason={setSeasonStr}
regex={regexStr}
onRegex={setRegexStr}
regexOk={regexOk}
/>
</div>
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>}
@@ -6,11 +6,10 @@ import { qk } from '@/shared/api/query-keys'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
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 { ShowPicker } from '@/features/admin/shows/ShowPicker'
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
import { EpisodeParseFields } from './EpisodeParseFields'
import { matchShowByName } from './match-show'
import { useUploadStore } from './upload-store'
@@ -101,25 +100,13 @@ export function UploadToShowPanel({ onClose }: Readonly<{ onClose: () => void }>
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowSeason')}</Label>
<Input
type="number"
min={1}
placeholder={t('admin.media.toShowAuto')}
value={seasonStr}
onChange={(e) => setSeasonStr(e.target.value)}
/>
</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>
<EpisodeParseFields
season={seasonStr}
onSeason={setSeasonStr}
regex={regexStr}
onRegex={setRegexStr}
regexOk={regexOk}
/>
</div>
<p className="text-xs text-muted-foreground">
{t('admin.media.toShowHint')}