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')}