From 64a2dade74752cea17c7b3a4b6daca0ab262eaac Mon Sep 17 00:00:00 2001
From: Leonid Pershin
Date: Sun, 26 Jul 2026 00:28:22 +0300
Subject: [PATCH] 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.
---
.../Endpoints/AdminUserEndpoints.cs | 6 +-
.../TeleWave.Api/Endpoints/MediaEndpoints.cs | 14 +-
.../Admin/Users/ListUsers/ListUsersQuery.cs | 4 +-
.../Users/ListUsers/ListUsersQueryHandler.cs | 2 +
.../Common/Interfaces/IIdentityService.cs | 2 +
.../Media/ListMedia/ListMediaAssetsQuery.cs | 4 +-
.../ListMedia/ListMediaAssetsQueryHandler.cs | 26 +-
.../Media/MediaAssetDto.cs | 2 +
.../Media/Stats/GetMediaStatsQuery.cs | 11 +
.../Media/Stats/GetMediaStatsQueryHandler.cs | 40 +
.../src/TeleWave.Domain/Media/MediaAsset.cs | 14 +
.../Identity/IdentityService.cs | 23 +-
...25210453_MediaProcessingTiming.Designer.cs | 1023 +++++++++++++++++
.../20260725210453_MediaProcessingTiming.cs | 39 +
.../Migrations/AppDbContextModelSnapshot.cs | 6 +
.../Media/MediaStatsTests.cs | 55 +
.../Media/MediaAssetTests.cs | 25 +
.../features/admin/images/ImageGallery.tsx | 37 +-
.../src/features/admin/media/MediaPanel.tsx | 94 +-
frontend/src/features/admin/media/api.ts | 9 +
.../src/features/admin/roles/RolesPanel.tsx | 22 +-
.../src/features/admin/shows/ShowsPanel.tsx | 55 +-
.../src/features/admin/users/UsersPanel.tsx | 46 +-
frontend/src/features/admin/users/api.ts | 4 +
frontend/src/shared/api/types.ts | 7 +
frontend/src/shared/lib/i18n.ts | 30 +
frontend/src/shared/ui/sortable.tsx | 73 ++
27 files changed, 1634 insertions(+), 39 deletions(-)
create mode 100644 backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQuery.cs
create mode 100644 backend/src/TeleWave.Application/Media/Stats/GetMediaStatsQueryHandler.cs
create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725210453_MediaProcessingTiming.Designer.cs
create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725210453_MediaProcessingTiming.cs
create mode 100644 backend/tests/TeleWave.Application.Tests/Media/MediaStatsTests.cs
create mode 100644 frontend/src/shared/ui/sortable.tsx
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) : '—'}
+ |
|