diff --git a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs index 3835205..d2eb9a1 100644 --- a/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/AdminUserEndpoints.cs @@ -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 ); diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index 116ad1f..ae8cf4f 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -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(StatusCodes.Status201Created); admin.MapGet("", List).Produces>(); + admin.MapGet("/stats", Stats).Produces(); admin.MapGet("/{id:guid}", Get).Produces(); 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 Stats(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken); + return Results.Ok(result); + } + private static async Task Get( Guid id, ISender sender, diff --git a/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQuery.cs b/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQuery.cs index 0f63c58..7ac6bb4 100644 --- a/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQuery.cs +++ b/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQuery.cs @@ -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>; diff --git a/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs b/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs index e73150e..34bdd43 100644 --- a/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs +++ b/backend/src/TeleWave.Application/Admin/Users/ListUsers/ListUsersQueryHandler.cs @@ -17,6 +17,8 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService) query.Search, query.RoleId, query.IsBlocked, + query.Sort, + query.Desc, cancellationToken ); } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs b/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs index 23488d9..e0ad62e 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IIdentityService.cs @@ -68,6 +68,8 @@ public interface IIdentityService string? search, Guid? roleId, bool? isBlocked, + string? sort, + bool desc, CancellationToken cancellationToken ); diff --git a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs index 4423992..643e80c 100644 --- a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs +++ b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQuery.cs @@ -8,5 +8,7 @@ public sealed record ListMediaAssetsQuery( int Page, int PageSize, IReadOnlyList Statuses, - string? Search + string? Search, + string? Sort = null, + bool Desc = false ) : IQuery>; diff --git a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs index fee3aef..1216837 100644 --- a/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Media/ListMedia/ListMediaAssetsQueryHandler.cs @@ -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); diff --git a/backend/src/TeleWave.Application/Media/MediaAssetDto.cs b/backend/src/TeleWave.Application/Media/MediaAssetDto.cs index 3d8fcea..67ce127 100644 --- a/backend/src/TeleWave.Application/Media/MediaAssetDto.cs +++ b/backend/src/TeleWave.Application/Media/MediaAssetDto.cs @@ -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 ); } diff --git a/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQuery.cs b/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQuery.cs new file mode 100644 index 0000000..2668964 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQuery.cs @@ -0,0 +1,11 @@ +using LiteCqrs; + +namespace TeleWave.Application.Media.Stats; + +/// Сводка по обработке медиа: сколько сейчас в очереди/в обработке и среднее время обработки. +public sealed record GetMediaStatsQuery : IQuery; + +/// Ассетов в статусе Pending (ждут обработки) сейчас. +/// Ассетов в статусе Processing (обрабатываются) сейчас. +/// Среднее время обработки по недавним завершённым (Ready), сек; null — если нет данных. +public sealed record MediaStatsDto(int Queued, int Processing, double? AverageProcessingSeconds); diff --git a/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQueryHandler.cs b/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQueryHandler.cs new file mode 100644 index 0000000..f7436d5 --- /dev/null +++ b/backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQueryHandler.cs @@ -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 +{ + /// По скольким последним завершённым ассетам усредняем время обработки. + private const int AverageSample = 500; + + public async Task 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); + } +} diff --git a/backend/src/TeleWave.Domain/Media/MediaAsset.cs b/backend/src/TeleWave.Domain/Media/MediaAsset.cs index 51731b3..ded3869 100644 --- a/backend/src/TeleWave.Domain/Media/MediaAsset.cs +++ b/backend/src/TeleWave.Domain/Media/MediaAsset.cs @@ -35,6 +35,12 @@ public class MediaAsset /// Текст ошибки, если == . public string? ErrorMessage { get; private set; } + /// Когда началась обработка (переход в Processing) — для расчёта её длительности. + public DateTimeOffset? ProcessingStartedAt { get; private set; } + + /// Сколько заняла обработка завершённого ассета (Ready) = момент готовности − старт обработки. + 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(); } diff --git a/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs b/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs index d2dc8b9..1285ad2 100644 --- a/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs +++ b/backend/src/TeleWave.Infrastructure/Identity/IdentityService.cs @@ -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( diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725210453_MediaProcessingTiming.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725210453_MediaProcessingTiming.Designer.cs new file mode 100644 index 0000000..027128b --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725210453_MediaProcessingTiming.Designer.cs @@ -0,0 +1,1023 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260725210453_MediaProcessingTiming")] + partial class MediaProcessingTiming + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725210453_MediaProcessingTiming.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725210453_MediaProcessingTiming.cs new file mode 100644 index 0000000..c57b8a5 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725210453_MediaProcessingTiming.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class MediaProcessingTiming : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ProcessingDuration", + table: "MediaAssets", + type: "interval", + nullable: true); + + migrationBuilder.AddColumn( + name: "ProcessingStartedAt", + table: "MediaAssets", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ProcessingDuration", + table: "MediaAssets"); + + migrationBuilder.DropColumn( + name: "ProcessingStartedAt", + table: "MediaAssets"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 5a535b3..c738d2e 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -703,6 +703,12 @@ namespace TeleWave.Infrastructure.Migrations .HasMaxLength(512) .HasColumnType("character varying(512)"); + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + b.Property("RelativePath") .HasMaxLength(256) .HasColumnType("character varying(256)"); diff --git a/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs b/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs new file mode 100644 index 0000000..9a21404 --- /dev/null +++ b/backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs @@ -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); + } +} diff --git a/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs b/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs index 5f05972..e06a771 100644 --- a/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Media/MediaAssetTests.cs @@ -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() { diff --git a/frontend/src/features/admin/images/ImageGallery.tsx b/frontend/src/features/admin/images/ImageGallery.tsx index 543400f..fe932d7 100644 --- a/frontend/src/features/admin/images/ImageGallery.tsx +++ b/frontend/src/features/admin/images/ImageGallery.tsx @@ -1,16 +1,19 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Trash2, Upload } from 'lucide-react' -import { useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { HttpError } from '@/shared/api/client' import type { ImageCategory } from '@/shared/api/types' import { Button } from '@/shared/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { deleteImage, imageUrl, listImages, uploadImage } from './api' const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground'] +type ImageOrder = 'new' | 'old' | 'az' | 'za' + export type ImagePick = { id: string; url: string } /** @@ -35,11 +38,30 @@ export function GalleryBrowser({ const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')) + const [order, setOrder] = useState('new') + const { data: images, isLoading } = useQuery({ queryKey: ['admin', 'images', active], queryFn: () => listImages(active), }) + const sorted = useMemo(() => { + const arr = [...(images ?? [])] + arr.sort((a, b) => { + switch (order) { + case 'old': + return a.createdAt.localeCompare(b.createdAt) + case 'az': + return (a.originalFileName ?? '').localeCompare(b.originalFileName ?? '') + case 'za': + return (b.originalFileName ?? '').localeCompare(a.originalFileName ?? '') + default: + return b.createdAt.localeCompare(a.createdAt) + } + }) + return arr + }, [images, order]) + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'images', active] }) const pick = (id: string) => { @@ -87,6 +109,17 @@ export function GalleryBrowser({ {onSelect ? t('admin.gallery.pickHint') : t('admin.gallery.browseHint')} + {t('common.loading')}

) : images && images.length > 0 ? (
- {images.map((img) => ( + {sorted.map((img) => (
- {t('admin.media.name')} - {t('admin.media.status')} - {t('admin.media.duration')} - {t('admin.media.resolution')} + + + + + {t('common.actions')} {isLoading && ( - + {t('common.loading')} @@ -158,7 +229,7 @@ export function MediaPanel() { ))} {data && data.items.length === 0 && !isLoading && ( - + {t('admin.media.empty')} @@ -190,6 +261,9 @@ function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => v {asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'} + + {asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'} +