From 6ca629c13ad6914e9e13c895d4f792aa26e6d948 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 28 Jul 2026 10:46:21 +0300 Subject: [PATCH] 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. --- .gitea/workflows/ci.yml | 7 +- .../DependencyInjection.cs | 2 + .../ImportManualInboxCommandHandler.cs | 88 ++----------- .../Media/ManualInbox/ManualInboxTaker.cs | 79 +++++++++++ .../MovieImport/ImportMoviesCommandHandler.cs | 123 +++++------------- .../Media/ImportMoviesTests.cs | 11 +- .../ManualInboxIntegrationTests.cs | 6 +- .../admin/media/EpisodeParseFields.tsx | 50 +++++++ .../features/admin/media/ManualInboxPanel.tsx | 28 ++-- .../admin/media/UploadToShowPanel.tsx | 29 ++--- 10 files changed, 204 insertions(+), 219 deletions(-) create mode 100644 backend/src/TeleWave.Application/Media/ManualInbox/ManualInboxTaker.cs create mode 100644 frontend/src/features/admin/media/EpisodeParseFields.tsx diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 516923b..4eb26c4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая diff --git a/backend/src/TeleWave.Application/DependencyInjection.cs b/backend/src/TeleWave.Application/DependencyInjection.cs index 5807750..3914036 100644 --- a/backend/src/TeleWave.Application/DependencyInjection.cs +++ b/backend/src/TeleWave.Application/DependencyInjection.cs @@ -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(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs b/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs index cf68d0e..14599a3 100644 --- a/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/ManualInbox/ImportManualInboxCommandHandler.cs @@ -9,7 +9,7 @@ namespace TeleWave.Application.Media.ManualInbox; public sealed class ImportManualInboxCommandHandler( IAppDbContext dbContext, - IMediaStorage storage, + ManualInboxTaker taker, IMediaProcessingQueue queue ) : ICommandHandler> { @@ -24,9 +24,7 @@ public sealed class ImportManualInboxCommandHandler( if (show is null) return Result.Failure(ShowErrors.NotFound); - var available = storage - .ListManualInbox(int.MaxValue) - .ToDictionary(f => f.RelativePath, StringComparer.Ordinal); + var available = taker.List(); var failed = new List(); 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); } - - /// - /// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при обычной загрузке: - /// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы - /// не осталось записи без исходника. - /// - private async Task 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; - } } diff --git a/backend/src/TeleWave.Application/Media/ManualInbox/ManualInboxTaker.cs b/backend/src/TeleWave.Application/Media/ManualInbox/ManualInboxTaker.cs new file mode 100644 index 0000000..768ed46 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/ManualInbox/ManualInboxTaker.cs @@ -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; + +/// +/// Забирает файл из manual/ в библиотеку: проверки, регистрация ассета и перенос в +/// originals/. Общее для обоих разборов — серии в шоу и фильмы пачкой: правила «что можно взять» +/// и порядок «сначала строка в БД, потом файл» обязаны быть одни, иначе один разбор однажды +/// оставит на диске то, что другой убирает. +/// +public sealed class ManualInboxTaker(IAppDbContext dbContext, IMediaStorage storage) +{ + /// Что лежит в каталоге, по относительному пути — по нему файл и опознаётся. + public IReadOnlyDictionary List() => + storage + .ListManualInbox(int.MaxValue) + .ToDictionary(f => f.RelativePath, StringComparer.Ordinal); + + /// + /// Берёт файл: проверяет формат и дубль по имени, регистрирует ассет и переносит его в + /// originals/. Ошибка возвращается значением, а не исключением: в пакетном разборе одна + /// негодная строка не должна отменять остальные. + /// + public async Task> TakeAsync( + IReadOnlyDictionary available, + string relativePath, + CancellationToken cancellationToken + ) + { + if (!available.TryGetValue(relativePath, out var file)) + return Result.Failure(MediaErrors.SourceNotFound); + + if (!MediaFormats.IsAllowed(file.Name)) + return Result.Failure(MediaErrors.UnsupportedFormat); + + var duplicate = await dbContext.MediaAssets.AnyAsync( + a => a.OriginalFileName == file.Name && a.Status != MediaAssetStatus.Failed, + cancellationToken + ); + if (duplicate) + return Result.Failure(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(MediaErrors.SourceNotFound); + } + + return Result.Success(asset.Id); + } + + /// + /// Убирает то, что осталось в каталоге от забранного файла: спутники (субтитры, nfo, обложка) + /// и опустевшую папку. Без основного файла они мусор, за которым никто не вернётся. + /// + public Task CleanupAsync(string relativePath, CancellationToken cancellationToken) => + storage.CleanupManualLeftoversAsync(relativePath, cancellationToken); +} diff --git a/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs b/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs index b1bce43..47b4bb8 100644 --- a/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/MovieImport/ImportMoviesCommandHandler.cs @@ -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; /// public sealed class ImportMoviesCommandHandler( IAppDbContext dbContext, - IMediaStorage storage, + ManualInboxTaker taker, IMediaProcessingQueue queue, ShowMetadataApplier applier ) : ICommandHandler> @@ -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(); 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; } /// - /// Регистрирует ассет и переносит файл в originals/. Порядок тот же, что при ручном разборе: - /// сначала строка в БД, затем файл — при сбое переноса регистрация откатывается, чтобы - /// не осталось записи без исходника. + /// Уже загруженный браузером файл. В очередь он попал ещё при загрузке, поэтому второй раз + /// не ставится — иначе один и тот же исходник нарезался бы дважды. /// - private async Task RegisterAsync( - IMediaStorage.ManualInboxFile file, - string relativePath, + private async Task<(Guid AssetId, bool Enqueue)?> UploadedAsync( + MovieImportItem item, + Guid assetId, + List 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); } } diff --git a/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs b/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs index 847d842..46def3e 100644 --- a/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Media/ImportMoviesTests.cs @@ -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); diff --git a/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs index 686c929..863854f 100644 --- a/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/ManualInboxIntegrationTests.cs @@ -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() ).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() ).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() ).Handle( new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId), diff --git a/frontend/src/features/admin/media/EpisodeParseFields.tsx b/frontend/src/features/admin/media/EpisodeParseFields.tsx new file mode 100644 index 0000000..4351571 --- /dev/null +++ b/frontend/src/features/admin/media/EpisodeParseFields.tsx @@ -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 ( + <> +
+ + {/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */} + onSeason(e.target.value)} + /> +
+
+ + onRegex(e.target.value)} + className={!regexOk ? 'border-red-500' : undefined} + /> +
+ + ) +} diff --git a/frontend/src/features/admin/media/ManualInboxPanel.tsx b/frontend/src/features/admin/media/ManualInboxPanel.tsx index caf0d5a..4f4c9f1 100644 --- a/frontend/src/features/admin/media/ManualInboxPanel.tsx +++ b/frontend/src/features/admin/media/ManualInboxPanel.tsx @@ -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 }>) setQuery(e.target.value)} /> -
- - {/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */} - setSeasonStr(e.target.value)} - /> -
-
- - setRegexStr(e.target.value)} - className={!regexOk ? 'border-red-500' : undefined} - /> -
+ {!regexOk &&

{t('admin.media.toShowRegexInvalid')}

} diff --git a/frontend/src/features/admin/media/UploadToShowPanel.tsx b/frontend/src/features/admin/media/UploadToShowPanel.tsx index 5a09241..84a5491 100644 --- a/frontend/src/features/admin/media/UploadToShowPanel.tsx +++ b/frontend/src/features/admin/media/UploadToShowPanel.tsx @@ -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 }>
-
- - setSeasonStr(e.target.value)} - /> -
-
- - setRegexStr(e.target.value)} - className={!regexOk ? 'border-red-500' : undefined} - /> -
+

{t('admin.media.toShowHint')}