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 }) {
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
-
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}}',