From 2387223f0b65821bd7ab3abc0572cc13b74f34d8 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 16:17:24 +0300 Subject: [PATCH] Refactor AdminUserEndpoints and MediaEndpoints to include default parameter values for pagination and filtering, improving API usability. Update ManualInboxDialog to enhance show selection logic with auto-detection based on file names, and improve user feedback with new translations. Refactor PostgresFixture for better container management in integration tests. --- .../Endpoints/AdminUserEndpoints.cs | 20 +-- .../TeleWave.Api/Endpoints/MediaEndpoints.cs | 19 ++- .../Library/GetShow/GetShowQueryHandler.cs | 10 +- .../GetShowIntegrationTests.cs | 80 +++++++++++ .../PostgresFixture.cs | 133 +++++++++--------- .../QueryTranslationTests.cs | 75 ++++++++++ .../admin/media/ManualInboxDialog.tsx | 37 ++++- .../src/features/admin/media/MediaPanel.tsx | 3 +- frontend/src/shared/lib/i18n.ts | 2 + 9 files changed, 297 insertions(+), 82 deletions(-) create mode 100644 backend/tests/TeleWave.Integration.Tests/GetShowIntegrationTests.cs create mode 100644 backend/tests/TeleWave.Integration.Tests/QueryTranslationTests.cs diff --git a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs index b54049c..34c0419 100644 --- a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs @@ -32,16 +32,20 @@ public static class AdminUserEndpoints return app; } + /// + /// Все параметры со значениями по умолчанию: обязательный bool в query превращает + /// запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело. + /// private static async Task ListUsers( - int page, - int pageSize, - string? search, - Guid? roleId, - bool? isBlocked, - string? sort, - bool desc, ISender sender, - CancellationToken cancellationToken + CancellationToken cancellationToken, + int page = 1, + int pageSize = 20, + string? search = null, + Guid? roleId = null, + bool? isBlocked = null, + string? sort = null, + bool desc = false ) { var result = await sender.Send( diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index b4f395f..f2c1d33 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -109,15 +109,20 @@ public static class MediaEndpoints ); } + /// + /// Все параметры со значениями по умолчанию: обязательный bool в query заставлял + /// минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые ассеты», + /// которым сортировка не нужна. + /// private static async Task List( - int page, - int pageSize, - MediaAssetStatus[]? status, - string? search, - string? sort, - bool desc, ISender sender, - CancellationToken cancellationToken + CancellationToken cancellationToken, + int page = 1, + int pageSize = 20, + MediaAssetStatus[]? status = null, + string? search = null, + string? sort = null, + bool desc = false ) { var result = await sender.Send( diff --git a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs index 86e18bf..5165add 100644 --- a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs @@ -74,6 +74,8 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext) }) .ToList(); + // Сортировка идёт до сборки DTO: по свойству record'а EF порядок не переводит и падает + // на компиляции запроса («The LINQ expression could not be translated»). var collections = await dbContext .CollectionItems.AsNoTracking() .Where(i => i.ShowId == show.Id) @@ -82,9 +84,15 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext) item => item.CollectionId, collection => collection.Id, (item, collection) => - new ShowCollectionRefDto(collection.Id, collection.Name, item.Position) + new + { + collection.Id, + collection.Name, + item.Position, + } ) .OrderBy(c => c.Name) + .Select(c => new ShowCollectionRefDto(c.Id, c.Name, c.Position)) .ToListAsync(cancellationToken); return Result.Success( diff --git a/backend/tests/TeleWave.Integration.Tests/GetShowIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/GetShowIntegrationTests.cs new file mode 100644 index 0000000..64e75ee --- /dev/null +++ b/backend/tests/TeleWave.Integration.Tests/GetShowIntegrationTests.cs @@ -0,0 +1,80 @@ +using TeleWave.Application.Library.GetShow; +using TeleWave.Domain.Library; +using TeleWave.Domain.Media; +using TeleWave.Infrastructure.Persistence; +using Xunit; + +namespace TeleWave.Integration.Tests; + +/// +/// Карточка шоу против настоящей БД: запрос собирает серии, жанры и коллекции несколькими +/// проекциями, и часть из них EF может не суметь перевести — на InMemory такое не всплывает. +/// +[Collection("postgres")] +public sealed class GetShowIntegrationTests(PostgresFixture fixture) +{ + [SkippableFact] + public async Task GetShow_ReturnsEpisodesGenresAndCollections() + { + Skip.IfNot(fixture.Available, "Docker недоступен"); + + await using var seed = fixture.CreateContext(); + var showId = await SeedAsync(seed); + + await using var db = fixture.CreateContext(); + var result = await new GetShowQueryHandler(db).Handle(new GetShowQuery(showId), default); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value.Episodes.Count); + Assert.Equal(2, result.Value.Genres.Count); + Assert.Single(result.Value.Collections); + Assert.True(result.Value.Genres[0].IsPrimary); + Assert.All(result.Value.Episodes, e => Assert.NotNull(e.AssetName)); + } + + [SkippableFact] + public async Task GetShow_WithoutGenresAndCollections_Works() + { + Skip.IfNot(fixture.Available, "Docker недоступен"); + + await using var seed = fixture.CreateContext(); + var show = Show.Create($"Голое шоу {Guid.NewGuid():N}"[..20], ShowKind.Series); + seed.Shows.Add(show); + await seed.SaveChangesAsync(); + + await using var db = fixture.CreateContext(); + var result = await new GetShowQueryHandler(db).Handle(new GetShowQuery(show.Id), default); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value.Episodes); + Assert.Empty(result.Value.Genres); + Assert.Empty(result.Value.Collections); + } + + private static async Task SeedAsync(AppDbContext db) + { + var tag = Guid.NewGuid().ToString("N")[..8]; + + var action = Genre.Create($"Боевик {tag}", $"action-{tag}"); + var comedy = Genre.Create($"Комедия {tag}", $"comedy-{tag}"); + db.Genres.AddRange(action, comedy); + + var show = Show.Create($"Шоу {tag}", ShowKind.Series); + for (var i = 0; i < 2; i++) + { + var asset = MediaAsset.Register($"{tag}-s01e0{i}.mkv", ".mkv", MediaSource.Upload); + db.MediaAssets.Add(asset); + show.AddEpisode(asset.Id); + } + show.SetGenres([action.Id, comedy.Id], action.Id); + db.Shows.Add(show); + await db.SaveChangesAsync(); + + var collection = Collection.Create($"Франшиза {tag}"); + collection.AddShow(show.Id); + db.Collections.Add(collection); + await db.SaveChangesAsync(); + + return show.Id; + } +} diff --git a/backend/tests/TeleWave.Integration.Tests/PostgresFixture.cs b/backend/tests/TeleWave.Integration.Tests/PostgresFixture.cs index 910abc1..cdb5beb 100644 --- a/backend/tests/TeleWave.Integration.Tests/PostgresFixture.cs +++ b/backend/tests/TeleWave.Integration.Tests/PostgresFixture.cs @@ -1,63 +1,70 @@ -using Microsoft.EntityFrameworkCore; -using TeleWave.Infrastructure.Persistence; -using Testcontainers.PostgreSql; -using Xunit; - -namespace TeleWave.Integration.Tests; - -/// -/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие -/// экземпляры (каждый — своё соединение), чтобы тестировать то, что InMemory -/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker. -/// -public sealed class PostgresFixture : IAsyncLifetime -{ - private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() - .WithImage("postgres:16-alpine") - .Build(); - - /// false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются. - public bool Available { get; private set; } - - public string ConnectionString => _container.GetConnectionString(); - - public async Task InitializeAsync() - { - try - { - await _container.StartAsync(); - await using var db = CreateContext(); - await db.Database.MigrateAsync(); - Available = true; - } - catch (Exception) - { - // Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут. - Available = false; - } - } - - public AppDbContext CreateContext() => - new(new DbContextOptionsBuilder().UseNpgsql(ConnectionString).Options); - - public async Task DisposeAsync() => await _container.DisposeAsync(); -} - -[CollectionDefinition("postgres")] -public sealed class PostgresCollection : ICollectionFixture; - -/// Детерминированный источник случайности для планировщика в тестах. -internal sealed class SequenceRandom(params int[] sequence) - : Domain.Broadcast.Scheduling.IRandomSource -{ - private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence; - private int _i; - - public int Next(int maxExclusive) - { - if (maxExclusive <= 0) - return 0; - var value = _sequence[_i++ % _sequence.Length]; - return ((value % maxExclusive) + maxExclusive) % maxExclusive; - } -} +using Microsoft.EntityFrameworkCore; +using TeleWave.Infrastructure.Persistence; +using Testcontainers.PostgreSql; +using Xunit; + +namespace TeleWave.Integration.Tests; + +/// +/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие +/// экземпляры (каждый — своё соединение), чтобы тестировать то, что InMemory +/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker. +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + // Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен, + // и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска. + private PostgreSqlContainer? _container; + + /// false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются. + public bool Available { get; private set; } + + public string ConnectionString => + _container?.GetConnectionString() + ?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available."); + + public async Task InitializeAsync() + { + try + { + _container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build(); + await _container.StartAsync(); + await using var db = CreateContext(); + await db.Database.MigrateAsync(); + Available = true; + } + catch (Exception) + { + // Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут. + Available = false; + } + } + + public AppDbContext CreateContext() => + new(new DbContextOptionsBuilder().UseNpgsql(ConnectionString).Options); + + public async Task DisposeAsync() + { + if (_container is not null) + await _container.DisposeAsync(); + } +} + +[CollectionDefinition("postgres")] +public sealed class PostgresCollection : ICollectionFixture; + +/// Детерминированный источник случайности для планировщика в тестах. +internal sealed class SequenceRandom(params int[] sequence) + : Domain.Broadcast.Scheduling.IRandomSource +{ + private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence; + private int _i; + + public int Next(int maxExclusive) + { + if (maxExclusive <= 0) + return 0; + var value = _sequence[_i++ % _sequence.Length]; + return ((value % maxExclusive) + maxExclusive) % maxExclusive; + } +} diff --git a/backend/tests/TeleWave.Integration.Tests/QueryTranslationTests.cs b/backend/tests/TeleWave.Integration.Tests/QueryTranslationTests.cs new file mode 100644 index 0000000..c019add --- /dev/null +++ b/backend/tests/TeleWave.Integration.Tests/QueryTranslationTests.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Library.Collections; +using TeleWave.Infrastructure.Persistence; +using Xunit; + +namespace TeleWave.Integration.Tests; + +/// +/// Проверка переводимости запросов в SQL. Соединение не открывается: ToQueryString компилирует +/// запрос и падает ровно там, где падал бы прод («The LINQ expression could not be translated»). +/// Поэтому Docker не нужен, и такие ошибки ловятся быстро. +/// +/// Повод: карточка шоу отвечала 500, потому что сортировка стояла после сборки DTO — EF не переводит +/// порядок по свойству record'а. +/// +public sealed class QueryTranslationTests : IDisposable +{ + private readonly AppDbContext _db = new( + new DbContextOptionsBuilder() + // Сервера по адресу нет и не требуется — запрос только компилируется. + .UseNpgsql("Host=127.0.0.1;Port=1;Database=telewave;Username=u;Password=p") + .Options + ); + + [Fact] + public void ShowCollections_Translates() + { + var showId = Guid.NewGuid(); + + var sql = _db + .CollectionItems.AsNoTracking() + .Where(i => i.ShowId == showId) + .Join( + _db.Collections.AsNoTracking(), + item => item.CollectionId, + collection => collection.Id, + (item, collection) => + new + { + collection.Id, + collection.Name, + item.Position, + } + ) + .OrderBy(c => c.Name) + .Select(c => new ShowCollectionRefDto(c.Id, c.Name, c.Position)) + .ToQueryString(); + + Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ShowCollections_OrderingAfterProjection_DoesNotTranslate() + { + // Зафиксировано намеренно: именно эта форма роняла карточку шоу в 500. Если EF когда-нибудь + // научится её переводить, тест упадёт — и это будет поводом упростить запрос обратно. + var showId = Guid.NewGuid(); + + var query = _db + .CollectionItems.AsNoTracking() + .Where(i => i.ShowId == showId) + .Join( + _db.Collections.AsNoTracking(), + item => item.CollectionId, + collection => collection.Id, + (item, collection) => + new ShowCollectionRefDto(collection.Id, collection.Name, item.Position) + ) + .OrderBy(c => c.Name); + + Assert.Throws(() => query.ToQueryString()); + } + + public void Dispose() => _db.Dispose(); +} diff --git a/frontend/src/features/admin/media/ManualInboxDialog.tsx b/frontend/src/features/admin/media/ManualInboxDialog.tsx index b9be0fe..1ccc014 100644 --- a/frontend/src/features/admin/media/ManualInboxDialog.tsx +++ b/frontend/src/features/admin/media/ManualInboxDialog.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ChevronDown, ChevronRight, Folder } from 'lucide-react' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { listShows } from '@/features/admin/shows/api' import { HttpError } from '@/shared/api/client' @@ -22,6 +22,7 @@ import { toast } from '@/shared/ui/toast-store' import { importManualInbox, listManualInbox } from './api' import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse' import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex' +import { matchShowByName } from './match-show' /** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */ function formatSize(bytes: number): string { @@ -48,6 +49,8 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) { const queryClient = useQueryClient() const [selected, setSelected] = useState([]) const [showId, setShowId] = useState('') + // Ручной выбор шоу отключает автоопределение: перебивать решение человека нельзя. + const [showPicked, setShowPicked] = useState(false) const [query, setQuery] = useState('') const [seasonStr, setSeasonStr] = useState('') const [regexStr, setRegexStr] = useState('') @@ -120,6 +123,25 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) { parts.push({ text: sample.name.slice(cursor), number: null }) return parts }, [sample]) + /** + * Автоопределение шоу по имени релиза — то же, что в загрузке в шоу. Сначала пробуем имя файла, + * затем имя папки: в раздачах название сериала обычно есть и там, и там («Mr.Pickles.S01.1080p»). + */ + const detectedShowId = useMemo( + () => + shows && sample + ? (matchShowByName(sample.name, shows) ?? matchShowByName(sample.folder, shows)) + : undefined, + [shows, sample], + ) + + useEffect(() => { + if (showPicked || showId || !detectedShowId) return + setShowId(detectedShowId) + }, [detectedShowId, showPicked, showId]) + + const autoDetected = !showPicked && !!detectedShowId && showId === detectedShowId + const recognized = selectable.filter( (f) => parsedByPath.get(f.relativePath)?.episode != null, ).length @@ -180,7 +202,13 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) { {/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
- { + setShowPicked(true) + setShowId(value) + }} + > @@ -192,6 +220,11 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) { ))} + {autoDetected && ( +

+ {t('admin.media.manualDetected')} +

+ )}
diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx index 995875a..8bd179d 100644 --- a/frontend/src/features/admin/media/MediaPanel.tsx +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -212,6 +212,7 @@ export function MediaPanel() { sortKey="status" sort={sort} onToggle={sortColumn} + className="whitespace-nowrap" /> v return ( {asset.originalFileName} - + {t(`admin.media.statuses.${asset.status}`)} diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 8c7060f..c290497 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -226,6 +226,7 @@ const resources = { 'Файлы уйдут из папки, спутники (субтитры, nfo) и опустевший каталог будут удалены.', manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.', manualShow: 'Шоу', + manualDetected: 'Определено по имени релиза — проверьте и поправьте, если не то.', manualPickShow: 'Выберите шоу', manualImport: 'Забрать в шоу', manualImported: 'Импортировано файлов: {{count}}', @@ -917,6 +918,7 @@ const resources = { 'Files leave the folder; siblings (subtitles, nfo) and the emptied folder are removed.', manualTruncated: 'Showing the first 500 files — there are more in the folder.', manualShow: 'Show', + manualDetected: 'Detected from the release name — check it and change if wrong.', manualPickShow: 'Pick a show', manualImport: 'Import into show', manualImported: 'Files imported: {{count}}',