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.
This commit is contained in:
@@ -32,16 +32,20 @@ public static class AdminUserEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все параметры со значениями по умолчанию: обязательный <c>bool</c> в query превращает
|
||||
/// запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело.
|
||||
/// </summary>
|
||||
private static async Task<IResult> 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(
|
||||
|
||||
@@ -109,15 +109,20 @@ public static class MediaEndpoints
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все параметры со значениями по умолчанию: обязательный <c>bool</c> в query заставлял
|
||||
/// минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые ассеты»,
|
||||
/// которым сортировка не нужна.
|
||||
/// </summary>
|
||||
private static async Task<IResult> 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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Карточка шоу против настоящей БД: запрос собирает серии, жанры и коллекции несколькими
|
||||
/// проекциями, и часть из них EF может не суметь перевести — на InMemory такое не всплывает.
|
||||
/// </summary>
|
||||
[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<Guid> 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;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
||||
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
||||
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.Build();
|
||||
|
||||
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
||||
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<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
||||
|
||||
public async Task DisposeAsync() => await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
[CollectionDefinition("postgres")]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
||||
|
||||
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
||||
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
||||
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
// Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен,
|
||||
// и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска.
|
||||
private PostgreSqlContainer? _container;
|
||||
|
||||
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
||||
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<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_container is not null)
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("postgres")]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
||||
|
||||
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Library.Collections;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Проверка переводимости запросов в SQL. Соединение не открывается: <c>ToQueryString</c> компилирует
|
||||
/// запрос и падает ровно там, где падал бы прод («The LINQ expression could not be translated»).
|
||||
/// Поэтому Docker не нужен, и такие ошибки ловятся быстро.
|
||||
///
|
||||
/// Повод: карточка шоу отвечала 500, потому что сортировка стояла после сборки DTO — EF не переводит
|
||||
/// порядок по свойству record'а.
|
||||
/// </summary>
|
||||
public sealed class QueryTranslationTests : IDisposable
|
||||
{
|
||||
private readonly AppDbContext _db = new(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
// Сервера по адресу нет и не требуется — запрос только компилируется.
|
||||
.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<InvalidOperationException>(() => query.ToQueryString());
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
@@ -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<string[]>([])
|
||||
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 }) {
|
||||
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.media.manualShow')}</Label>
|
||||
<Select value={showId} onValueChange={setShowId}>
|
||||
<Select
|
||||
value={showId}
|
||||
onValueChange={(value) => {
|
||||
setShowPicked(true)
|
||||
setShowId(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
||||
</SelectTrigger>
|
||||
@@ -192,6 +220,11 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{autoDetected && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.media.manualDetected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
|
||||
@@ -212,6 +212,7 @@ export function MediaPanel() {
|
||||
sortKey="status"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.media.duration')}
|
||||
@@ -274,7 +275,7 @@ function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => v
|
||||
return (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">{asset.originalFileName}</td>
|
||||
<td className="px-4 py-2">
|
||||
<td className="whitespace-nowrap px-4 py-2">
|
||||
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
|
||||
{t(`admin.media.statuses.${asset.status}`)}
|
||||
</Badge>
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
Reference in New Issue
Block a user