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) : '—'}
+ |
|