Enhance user and media management with sorting and statistics: add sorting options to user and media listing endpoints, implement media statistics retrieval, and update frontend components for sorting and displaying media processing times. Refactor related query handlers and API types to support new features.
build / backend (push) Successful in 1m35s
build / frontend (push) Successful in 31s
tests / backend-tests (push) Successful in 1m48s

This commit is contained in:
Leonid Pershin
2026-07-26 00:28:22 +03:00
parent d6ad8e11a4
commit 64a2dade74
27 changed files with 1634 additions and 39 deletions
@@ -40,6 +40,8 @@ public static class AdminUserEndpoints
string? search,
Guid? roleId,
bool? isBlocked,
string? sort,
bool desc,
ISender sender,
CancellationToken cancellationToken
)
@@ -50,7 +52,9 @@ public static class AdminUserEndpoints
pageSize <= 0 ? 20 : pageSize,
search,
roleId,
isBlocked
isBlocked,
sort,
desc
),
cancellationToken
);
@@ -8,6 +8,7 @@ using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.GetMedia;
using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
@@ -24,6 +25,7 @@ public static class MediaEndpoints
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
@@ -100,6 +102,8 @@ public static class MediaEndpoints
int pageSize,
MediaAssetStatus[]? status,
string? search,
string? sort,
bool desc,
ISender sender,
CancellationToken cancellationToken
)
@@ -109,13 +113,21 @@ public static class MediaEndpoints
page <= 0 ? 1 : page,
pageSize <= 0 ? 20 : pageSize,
status ?? [],
search
search,
sort,
desc
),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Get(
Guid id,
ISender sender,
@@ -9,5 +9,7 @@ public sealed record ListUsersQuery(
int PageSize,
string? Search,
Guid? RoleId,
bool? IsBlocked
bool? IsBlocked,
string? Sort = null,
bool Desc = false
) : IQuery<PagedList<UserSummaryDto>>;
@@ -17,6 +17,8 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService)
query.Search,
query.RoleId,
query.IsBlocked,
query.Sort,
query.Desc,
cancellationToken
);
}
@@ -68,6 +68,8 @@ public interface IIdentityService
string? search,
Guid? roleId,
bool? isBlocked,
string? sort,
bool desc,
CancellationToken cancellationToken
);
@@ -8,5 +8,7 @@ public sealed record ListMediaAssetsQuery(
int Page,
int PageSize,
IReadOnlyList<MediaAssetStatus> Statuses,
string? Search
string? Search,
string? Sort = null,
bool Desc = false
) : IQuery<PagedList<MediaAssetDto>>;
@@ -30,9 +30,33 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext)
var total = await q.CountAsync(cancellationToken);
// Сортировка по выбранному столбцу (в SQL, до пагинации); вторичный ключ — Id для стабильного
// порядка при равенстве значений (иначе страницы «дрожат»).
var ordered = (query.Sort?.ToLowerInvariant()) switch
{
"name" => query.Desc
? q.OrderByDescending(x => x.OriginalFileName)
: q.OrderBy(x => x.OriginalFileName),
"status" => query.Desc ? q.OrderByDescending(x => x.Status) : q.OrderBy(x => x.Status),
"duration" => query.Desc
? q.OrderByDescending(x => x.Duration)
: q.OrderBy(x => x.Duration),
"resolution" => query.Desc
? q.OrderByDescending(x => x.Width)
: q.OrderBy(x => x.Width),
"processing" => query.Desc
? q.OrderByDescending(x => x.ProcessingDuration)
: q.OrderBy(x => x.ProcessingDuration),
"created" => query.Desc
? q.OrderByDescending(x => x.CreatedAt)
: q.OrderBy(x => x.CreatedAt),
_ => q.OrderByDescending(x => x.CreatedAt),
};
// Маппинг в памяти: MediaAssetDto.From обращается к TimeSpan.TotalSeconds, который EF в SQL
// не переводит. Страница ограничена pageSize, поэтому материализация сущностей безопасна.
var entities = await q.OrderByDescending(x => x.CreatedAt)
var entities = await ordered
.ThenBy(x => x.Id)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync(cancellationToken);
@@ -14,6 +14,7 @@ public sealed record MediaAssetDto(
string? VideoCodec,
string? AudioCodec,
string? ErrorMessage,
double? ProcessingSeconds,
DateTimeOffset CreatedAt
)
{
@@ -30,6 +31,7 @@ public sealed record MediaAssetDto(
asset.VideoCodec,
asset.AudioCodec,
asset.ErrorMessage,
asset.ProcessingDuration?.TotalSeconds,
asset.CreatedAt
);
}
@@ -0,0 +1,11 @@
using LiteCqrs;
namespace TeleWave.Application.Media.Stats;
/// <summary>Сводка по обработке медиа: сколько сейчас в очереди/в обработке и среднее время обработки.</summary>
public sealed record GetMediaStatsQuery : IQuery<MediaStatsDto>;
/// <param name="Queued">Ассетов в статусе Pending (ждут обработки) сейчас.</param>
/// <param name="Processing">Ассетов в статусе Processing (обрабатываются) сейчас.</param>
/// <param name="AverageProcessingSeconds">Среднее время обработки по недавним завершённым (Ready), сек; null — если нет данных.</param>
public sealed record MediaStatsDto(int Queued, int Processing, double? AverageProcessingSeconds);
@@ -0,0 +1,40 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Media.Stats;
public sealed class GetMediaStatsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetMediaStatsQuery, MediaStatsDto>
{
/// <summary>По скольким последним завершённым ассетам усредняем время обработки.</summary>
private const int AverageSample = 500;
public async Task<MediaStatsDto> Handle(
GetMediaStatsQuery query,
CancellationToken cancellationToken
)
{
// Сгенерированные (ТВ-заставки) в статистику библиотеки не входят.
var assets = dbContext.MediaAssets.AsNoTracking().Where(x => x.Source != MediaSource.Generated);
var queued = await assets.CountAsync(x => x.Status == MediaAssetStatus.Pending, cancellationToken);
var processing = await assets.CountAsync(
x => x.Status == MediaAssetStatus.Processing,
cancellationToken
);
// Среднее — по недавним завершённым (ограничение выборки, чтобы не тянуть всю историю). EF не
// усредняет TimeSpan в SQL, поэтому берём длительности последних N и усредняем в памяти.
var recent = await assets
.Where(x => x.Status == MediaAssetStatus.Ready && x.ProcessingDuration != null)
.OrderByDescending(x => x.UpdatedAt)
.Select(x => x.ProcessingDuration!.Value)
.Take(AverageSample)
.ToListAsync(cancellationToken);
double? average = recent.Count > 0 ? recent.Average(d => d.TotalSeconds) : null;
return new MediaStatsDto(queued, processing, average);
}
}
@@ -35,6 +35,12 @@ public class MediaAsset
/// <summary>Текст ошибки, если <see cref="Status"/> == <see cref="MediaAssetStatus.Failed"/>.</summary>
public string? ErrorMessage { get; private set; }
/// <summary>Когда началась обработка (переход в Processing) — для расчёта её длительности.</summary>
public DateTimeOffset? ProcessingStartedAt { get; private set; }
/// <summary>Сколько заняла обработка завершённого ассета (Ready) = момент готовности − старт обработки.</summary>
public TimeSpan? ProcessingDuration { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset UpdatedAt { get; private set; }
@@ -79,6 +85,8 @@ public class MediaAsset
{
Status = MediaAssetStatus.Processing;
ErrorMessage = null;
ProcessingStartedAt = DateTimeOffset.UtcNow;
ProcessingDuration = null;
Touch();
}
@@ -103,6 +111,11 @@ public class MediaAsset
AudioCodec = audioCodec;
RelativePath = relativePath;
ErrorMessage = null;
if (ProcessingStartedAt is { } startedAt)
{
var elapsed = DateTimeOffset.UtcNow - startedAt;
ProcessingDuration = elapsed > TimeSpan.Zero ? elapsed : TimeSpan.Zero;
}
Touch();
}
@@ -118,6 +131,7 @@ public class MediaAsset
{
Status = MediaAssetStatus.Pending;
ErrorMessage = null;
ProcessingStartedAt = null;
Touch();
}
@@ -198,6 +198,8 @@ internal sealed class IdentityService(
string? search,
Guid? roleId,
bool? isBlocked,
string? sort,
bool desc,
CancellationToken cancellationToken
)
{
@@ -225,8 +227,25 @@ internal sealed class IdentityService(
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.user.CreatedAt)
var ordered = (sort?.ToLowerInvariant()) switch
{
"username" => desc
? query.OrderByDescending(x => x.user.UserName)
: query.OrderBy(x => x.user.UserName),
"role" => desc
? query.OrderByDescending(x => x.RoleName)
: query.OrderBy(x => x.RoleName),
"blocked" => desc
? query.OrderByDescending(x => x.user.IsBlocked)
: query.OrderBy(x => x.user.IsBlocked),
"created" => desc
? query.OrderByDescending(x => x.user.CreatedAt)
: query.OrderBy(x => x.user.CreatedAt),
_ => query.OrderByDescending(x => x.user.CreatedAt),
};
var items = await ordered
.ThenBy(x => x.user.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new UserSummaryDto(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MediaProcessingTiming : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<TimeSpan>(
name: "ProcessingDuration",
table: "MediaAssets",
type: "interval",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ProcessingStartedAt",
table: "MediaAssets",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProcessingDuration",
table: "MediaAssets");
migrationBuilder.DropColumn(
name: "ProcessingStartedAt",
table: "MediaAssets");
}
}
}
@@ -703,6 +703,12 @@ namespace TeleWave.Infrastructure.Migrations
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<TimeSpan?>("ProcessingDuration")
.HasColumnType("interval");
b.Property<DateTimeOffset?>("ProcessingStartedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RelativePath")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
@@ -0,0 +1,55 @@
using TeleWave.Application.Media.Stats;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Media;
using Xunit;
namespace TeleWave.Application.Tests.Media;
public class MediaStatsTests
{
private static MediaAsset Pending(string name) =>
MediaAsset.Register(name, ".mkv", MediaSource.Upload);
private static MediaAsset Processing(string name)
{
var a = Pending(name);
a.MarkProcessing();
return a;
}
private static MediaAsset Ready(string name)
{
var a = Pending(name);
a.MarkProcessing();
a.MarkReady(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x");
return a;
}
[Fact]
public async Task Stats_CountsQueuedAndProcessing_AndAveragesReady_ExcludingGenerated()
{
var fixture = new TestDb();
await using (var seed = fixture.New())
{
seed.MediaAssets.AddRange(
Pending("a.mkv"),
Pending("b.mkv"),
Processing("c.mkv"),
Ready("d.mkv"),
MediaAsset.RegisterGenerated("Заставка") // Generated — не входит в статистику
);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var stats = await new GetMediaStatsQueryHandler(db).Handle(
new GetMediaStatsQuery(),
CancellationToken.None
);
Assert.Equal(2, stats.Queued); // два Pending (Generated исключён)
Assert.Equal(1, stats.Processing);
Assert.NotNull(stats.AverageProcessingSeconds);
Assert.True(stats.AverageProcessingSeconds >= 0);
}
}
@@ -55,6 +55,31 @@ public class MediaAssetTests
Assert.Null(asset.ErrorMessage);
}
[Fact]
public void MarkProcessing_RecordsStart_AndMarkReady_ComputesProcessingDuration()
{
var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload);
asset.MarkProcessing();
Assert.NotNull(asset.ProcessingStartedAt);
Assert.Null(asset.ProcessingDuration);
asset.MarkReady(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc");
Assert.NotNull(asset.ProcessingDuration);
Assert.True(asset.ProcessingDuration >= TimeSpan.Zero);
}
[Fact]
public void MarkReady_WithoutProcessingStart_LeavesDurationNull()
{
var asset = MediaAsset.Register("a.mp4", ".mp4", MediaSource.Upload);
asset.MarkReady(TimeSpan.FromSeconds(120), 2, 60, 1920, 1080, "h264", "aac", "assets/abc");
Assert.Null(asset.ProcessingDuration);
}
[Fact]
public void MarkFailed_StoresError()
{