Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64a2dade74 | ||
|
|
d6ad8e11a4 |
@@ -118,6 +118,8 @@ Backend (из `backend/`):
|
|||||||
```bash
|
```bash
|
||||||
dotnet build
|
dotnet build
|
||||||
dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
|
dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
|
||||||
|
# Интеграционные тесты (Testcontainers-Postgres) — нужен запущенный Docker; без него пропускаются:
|
||||||
|
dotnet test tests/TeleWave.Integration.Tests
|
||||||
dotnet run --project src/TeleWave.Api
|
dotnet run --project src/TeleWave.Api
|
||||||
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
|
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
|
||||||
dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
|
dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
### Планировщик / медиа
|
### Планировщик / медиа
|
||||||
- [x] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs` + `MediaOptions` (ffprobe 120с, транскод/рендер 1800с, конфигурируемо).
|
- [x] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs` + `MediaOptions` (ffprobe 120с, транскод/рендер 1800с, конфигурируемо).
|
||||||
- [x] **M9. Гонка при конкурентной генерации расписания канала** — `ScheduleGenerator.cs` (транзакция + `pg_advisory_xact_lock` по каналу).
|
- [x] **M9. Гонка при конкурентной генерации расписания канала** — `ScheduleGenerator.cs` (транзакция + `pg_advisory_xact_lock` по каналу).
|
||||||
- [~] **M10. Рендер заставок синхронно внутри тика планировщика** — `ScheduleBumperResolver.cs:174`. _(частично: M8 ограничивает худший случай, кэш делает рендеры редкими, M9 сериализует канал. Полный вынос рендера из тика — отдельная архитектурная задача, флажок ниже.)_
|
- [x] **M10. Рендер заставок синхронно внутри тика планировщика** — вынесен в асинхронную очередь: планировщик создаёт ассет заставки в Pending + кэш-строку и ставит в очередь, ffmpeg крутит новый `BumperRenderBackgroundService` вне тика/транзакции. До готовности плейлист отдаёт филлер.
|
||||||
- [x] **M11. Override через полночь не работает** — `SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно).
|
- [x] **M11. Override через полночь не работает** — `SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно).
|
||||||
|
|
||||||
### Фронтенд
|
### Фронтенд
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
- [x] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json** — осознанные dev-дефолты, переопределяются env в проде; C2 теперь форсит реальный JWT-ключ. Оставлено как задокументированное.
|
- [x] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json** — осознанные dev-дефолты, переопределяются env в проде; C2 теперь форсит реальный JWT-ключ. Оставлено как задокументированное.
|
||||||
- [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https).
|
- [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https).
|
||||||
- [x] **L11. Документация (CLAUDE.md/README) отстала** — обновлены разделы «текущее состояние» и рабочий принцип под реальный объём.
|
- [x] **L11. Документация (CLAUDE.md/README) отстала** — обновлены разделы «текущее состояние» и рабочий принцип под реальный объём.
|
||||||
- [~] **L12. Пробелы в тестах** — частично: добавлены тесты на M11 (override через полночь), L3 (Single). Полное покрытие фоновых сервисов/оркестрации/эндпоинтов — отдельная задача.
|
- [x] **L12. Пробелы в тестах** — добавлен проект `TeleWave.Integration.Tests` на Testcontainers-Postgres: генерация расписания, M10 (Pending-заставки + очередь), M9 (advisory-lock против гонки), M7 (откат Register), M5 (транзакция DeleteShowMedia). При отсутствии Docker тесты пропускаются (SkippableFact). Плюс unit-тесты на M11/L3.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -166,13 +166,43 @@ Api:EnableOpenApi неявно). Тесты: +2 domain (override через по
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Осталось (флажки на будущее — требуют отдельного решения)
|
## Журнал — M10 (async-рендер заставок) и L12 (интеграционные тесты)
|
||||||
|
|
||||||
- **M10 (полностью)** — вынести рендер ТВ-заставок из тика планировщика: генерировать расписание с
|
### 2026-07-25 — M10: асинхронная очередь рендера заставок
|
||||||
плейсхолдерами и рендерить ассеты асинхронно, чтобы один канал не задерживал достройку остальных и
|
|
||||||
чтобы не держать транзакцию/advisory-lock во время ffmpeg.
|
- **Домен**: `BumperAsset` получил `ChannelId`/`TemplateId`/`VariantId` (чтобы фоновый рендерер
|
||||||
- **L12 (полностью)** — интеграционные тесты фоновых сервисов (media-конвейер, планировщик),
|
восстановил спецификацию), фабрика обновлена; миграция `BumperAssetRenderContext` (Guid-колонки с
|
||||||
оркестрации `ScheduleGenerator`, эндпоинтов (нужен реальный/контейнерный Postgres — InMemory не
|
дефолтом для существующих строк — самовосстановление при перерендере). `MediaAsset.RegisterGenerated`
|
||||||
тянет транзакции/advisory-lock/raw SQL).
|
теперь честно Pending до готовности.
|
||||||
- **M15 (доменно)** — если нужны разные файлы с одинаковым именем: пересмотреть уникальность
|
- **Очередь**: `IBumperRenderQueue` + `BumperRenderQueue` (сигнал поверх Channel, как у медиа).
|
||||||
`MediaAsset` (сейчас по `OriginalFileName`).
|
- **`ScheduleBumperResolver`**: больше не рендерит — создаёт Pending-ассет (Source=Generated) + кэш-строку
|
||||||
|
и ставит в очередь; переиспользует Ready/Pending/Processing, перерендерит только Failed (по новейшей
|
||||||
|
кэш-строке). Тик планировщика и его транзакция больше не держатся во время ffmpeg (снимает и остаток M9).
|
||||||
|
- **`BumperRenderBackgroundService`**: последовательно берёт Pending Generated → восстанавливает спеку
|
||||||
|
(`BumperSpecFactory`) → ffmpeg → MarkReady/MarkFailed; сброс прерванных на старте; idle-poll 30с.
|
||||||
|
- **`MediaProcessingBackgroundService`**: Generated-ассеты исключены из claim/reset (их обслуживает
|
||||||
|
рендерер заставок).
|
||||||
|
- **Стриминг менять не пришлось**: `GetLivePlaylistQueryHandler` уже фильтрует по `Status==Ready` →
|
||||||
|
Pending-заставка автоматически деградирует в филлер, пока рендерится.
|
||||||
|
- **Поведение**: заставка перехода впервые появляется на тик позже (до этого — филлер); дальше берётся
|
||||||
|
из кэша. Осознанный компромисс (выбран владельцем).
|
||||||
|
|
||||||
|
### 2026-07-25 — L12: интеграционные тесты на Testcontainers-Postgres
|
||||||
|
|
||||||
|
Новый проект `tests/TeleWave.Integration.Tests` (в `.slnx`): поднимает `postgres:16-alpine`, применяет
|
||||||
|
реальные миграции, гоняет то, что InMemory не умеет (транзакции, advisory-lock, ExecuteDelete). 5 тестов:
|
||||||
|
генерация Program-расписания; создание Pending-заставок (Generated) + постановка в очередь (M10);
|
||||||
|
сериализация конкурентной генерации advisory-lock'ом без перекрытий (M9); откат Register при сбое
|
||||||
|
переноса файла (M7); атомарное удаление в DeleteShowMedia (M5). При отсутствии Docker — `SkippableFact`
|
||||||
|
пропускает (CI на раннере без Docker не падает; `dotnet test TeleWave.slnx` в CI прогонит их при наличии
|
||||||
|
Docker на ubuntu-latest).
|
||||||
|
|
||||||
|
Проверка: сборка решения 0/0; **98 domain + 49 application + 5 integration** зелёные; фронт `tsc`/`oxlint`
|
||||||
|
чистые.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Осталось (доменное решение владельца, не дефект)
|
||||||
|
|
||||||
|
- **M15 (доменно)** — если понадобятся разные файлы с одинаковым именем: пересмотреть уникальность
|
||||||
|
`MediaAsset` (сейчас по `OriginalFileName` — намеренно, клиент это зеркалит).
|
||||||
|
|||||||
@@ -43,5 +43,7 @@
|
|||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
<PackageVersion Include="NSubstitute" Version="6.0.0" />
|
<PackageVersion Include="NSubstitute" Version="6.0.0" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
|
||||||
|
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -8,5 +8,6 @@
|
|||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" />
|
<Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" />
|
||||||
<Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" />
|
<Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" />
|
||||||
|
<Project Path="tests/TeleWave.Integration.Tests/TeleWave.Integration.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public static class AdminUserEndpoints
|
|||||||
string? search,
|
string? search,
|
||||||
Guid? roleId,
|
Guid? roleId,
|
||||||
bool? isBlocked,
|
bool? isBlocked,
|
||||||
|
string? sort,
|
||||||
|
bool desc,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
@@ -50,7 +52,9 @@ public static class AdminUserEndpoints
|
|||||||
pageSize <= 0 ? 20 : pageSize,
|
pageSize <= 0 ? 20 : pageSize,
|
||||||
search,
|
search,
|
||||||
roleId,
|
roleId,
|
||||||
isBlocked
|
isBlocked,
|
||||||
|
sort,
|
||||||
|
desc
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using TeleWave.Application.Media.Delete;
|
|||||||
using TeleWave.Application.Media.GetMedia;
|
using TeleWave.Application.Media.GetMedia;
|
||||||
using TeleWave.Application.Media.ListMedia;
|
using TeleWave.Application.Media.ListMedia;
|
||||||
using TeleWave.Application.Media.Register;
|
using TeleWave.Application.Media.Register;
|
||||||
|
using TeleWave.Application.Media.Stats;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Infrastructure.Media;
|
||||||
@@ -24,6 +25,7 @@ public static class MediaEndpoints
|
|||||||
|
|
||||||
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
||||||
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
||||||
|
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
||||||
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
|
admin.MapGet("/{id:guid}", Get).Produces<MediaAssetDto>();
|
||||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
@@ -100,6 +102,8 @@ public static class MediaEndpoints
|
|||||||
int pageSize,
|
int pageSize,
|
||||||
MediaAssetStatus[]? status,
|
MediaAssetStatus[]? status,
|
||||||
string? search,
|
string? search,
|
||||||
|
string? sort,
|
||||||
|
bool desc,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
@@ -109,13 +113,21 @@ public static class MediaEndpoints
|
|||||||
page <= 0 ? 1 : page,
|
page <= 0 ? 1 : page,
|
||||||
pageSize <= 0 ? 20 : pageSize,
|
pageSize <= 0 ? 20 : pageSize,
|
||||||
status ?? [],
|
status ?? [],
|
||||||
search
|
search,
|
||||||
|
sort,
|
||||||
|
desc
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Get(
|
private static async Task<IResult> Get(
|
||||||
Guid id,
|
Guid id,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
|
|||||||
@@ -9,5 +9,7 @@ public sealed record ListUsersQuery(
|
|||||||
int PageSize,
|
int PageSize,
|
||||||
string? Search,
|
string? Search,
|
||||||
Guid? RoleId,
|
Guid? RoleId,
|
||||||
bool? IsBlocked
|
bool? IsBlocked,
|
||||||
|
string? Sort = null,
|
||||||
|
bool Desc = false
|
||||||
) : IQuery<PagedList<UserSummaryDto>>;
|
) : IQuery<PagedList<UserSummaryDto>>;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public sealed class ListUsersQueryHandler(IIdentityService identityService)
|
|||||||
query.Search,
|
query.Search,
|
||||||
query.RoleId,
|
query.RoleId,
|
||||||
query.IsBlocked,
|
query.IsBlocked,
|
||||||
|
query.Sort,
|
||||||
|
query.Desc,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (пути к постеру/фону/звуку,
|
||||||
|
/// названия шоу). Общая точка для фонового рендерера заставок расписания и превью в админке.
|
||||||
|
/// </summary>
|
||||||
|
public static class BumperSpecFactory
|
||||||
|
{
|
||||||
|
public static BumperRenderSpec Build(
|
||||||
|
BumperOptions bumper,
|
||||||
|
BumperFont font,
|
||||||
|
BumperTemplate template,
|
||||||
|
BumperTextVariant variant,
|
||||||
|
int alignedDurationSeconds,
|
||||||
|
string fromName,
|
||||||
|
string toName,
|
||||||
|
string? audioPath,
|
||||||
|
string? posterAbsolutePath,
|
||||||
|
string? backgroundAbsolutePath
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var free = variant.Kind == BumperTextKind.Free;
|
||||||
|
return new BumperRenderSpec(
|
||||||
|
alignedDurationSeconds,
|
||||||
|
bumper.Width,
|
||||||
|
bumper.Height,
|
||||||
|
template.BackgroundColor,
|
||||||
|
template.BackgroundColor2,
|
||||||
|
template.AccentColor,
|
||||||
|
template.TextColor,
|
||||||
|
font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
|
||||||
|
free ? "" : variant.NowLabel,
|
||||||
|
free ? "" : fromName,
|
||||||
|
free ? "" : variant.NextLabel,
|
||||||
|
free ? "" : toName,
|
||||||
|
backgroundAbsolutePath,
|
||||||
|
audioPath,
|
||||||
|
posterAbsolutePath,
|
||||||
|
free,
|
||||||
|
variant.Line1,
|
||||||
|
variant.Line2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace TeleWave.Application.Broadcast.Scheduling;
|
|||||||
/// вверх до кратности длине сегмента — ключевой инвариант эфирной математики. Общая для генератора
|
/// вверх до кратности длине сегмента — ключевой инвариант эфирной математики. Общая для генератора
|
||||||
/// (слоты в плане) и резолвера заставок (рендер).
|
/// (слоты в плане) и резолвера заставок (рендер).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class BumperDuration
|
public static class BumperDuration
|
||||||
{
|
{
|
||||||
/// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary>
|
/// <summary>Длительность заставки без загруженного звука (сек) — синтезированный джингл.</summary>
|
||||||
private const int DefaultSeconds = 8;
|
private const int DefaultSeconds = 8;
|
||||||
|
|||||||
@@ -1,39 +1,37 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Broadcast.Scheduling;
|
using TeleWave.Domain.Broadcast.Scheduling;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Scheduling;
|
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки
|
/// Резолвит ассеты ТВ-заставок для запланированных переходов: для каждой уникальной тройки
|
||||||
/// «из→в→подблок» возвращает id готового ассета — из кэша (<see cref="BumperAsset"/>) либо
|
/// «из→в→подблок» возвращает id ассета-заставки. Сам ffmpeg НЕ запускает — если готового (или уже
|
||||||
/// свежесгенерированного ffmpeg-рендером. Работает через тот же scoped <see cref="IAppDbContext"/>,
|
/// рендерящегося) ассета в кэше нет, создаёт <see cref="MediaAsset"/> в статусе Pending, кэш-строку
|
||||||
/// что и <see cref="ScheduleGenerator"/>: добавленные ассеты сохраняются его общим SaveChanges.
|
/// <see cref="BumperAsset"/> (с id канала/блока/подблока для восстановления спеки) и ставит в очередь
|
||||||
|
/// фонового рендерера. Так тик планировщика не блокируется на ffmpeg, а его транзакция не держится во
|
||||||
|
/// время рендера. До готовности ассета плейлист отдаёт филлер (см. GetLivePlaylistQueryHandler).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ScheduleBumperResolver(
|
public sealed class ScheduleBumperResolver(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IBumperRenderer bumperRenderer,
|
IBumperRenderQueue renderQueue,
|
||||||
IBumperTemplateStorage bumperStorage,
|
|
||||||
IImageStore imageStore,
|
|
||||||
IOptions<BumperOptions> bumperOptions,
|
IOptions<BumperOptions> bumperOptions,
|
||||||
IOptions<StreamingOptions> streamingOptions,
|
IOptions<StreamingOptions> streamingOptions
|
||||||
ILogger<ScheduleBumperResolver> logger
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id готового
|
/// Для каждой уникальной тройки «из→в→подблок» из запланированных заставок возвращает id ассета:
|
||||||
/// ассета-заставки: из кэша либо свежесгенерированного.
|
/// готового/рендерящегося из кэша либо только что созданного Pending (поставлен в очередь рендера).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveAsync(
|
public async Task<Dictionary<(Guid From, Guid To, Guid Variant), Guid>> ResolveAsync(
|
||||||
Channel channel,
|
Channel channel,
|
||||||
@@ -68,52 +66,21 @@ public sealed class ScheduleBumperResolver(
|
|||||||
var fromIds = combos.Select(c => c.From).Distinct().ToList();
|
var fromIds = combos.Select(c => c.From).Distinct().ToList();
|
||||||
var toIds = combos.Select(c => c.To).Distinct().ToList();
|
var toIds = combos.Select(c => c.To).Distinct().ToList();
|
||||||
|
|
||||||
// Постеры шоу-получателей (из реестра изображений) — как фон заставки, если у блока нет
|
// Постер шоу-получателя участвует в сигнатуре (как токен id) — грузим только id картинки;
|
||||||
// своей фон-картинки. Резолвим id постера → расширение → абсолютный путь.
|
// абсолютный путь для рендера резолвит уже фоновый рендерер.
|
||||||
var showIds = fromIds.Concat(toIds).Distinct().ToList();
|
var showIds = fromIds.Concat(toIds).Distinct().ToList();
|
||||||
var posterShows = await dbContext
|
var posterImageByShow = await dbContext
|
||||||
.Shows.AsNoTracking()
|
.Shows.AsNoTracking()
|
||||||
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
|
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
|
||||||
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
|
.Select(s => new { s.Id, ImageId = s.PosterImageId!.Value })
|
||||||
.ToListAsync(cancellationToken);
|
.ToDictionaryAsync(s => s.Id, s => s.ImageId, cancellationToken);
|
||||||
var posterImageIds = posterShows.Select(p => p.ImageId).Distinct().ToList();
|
|
||||||
var posterExtById = await dbContext
|
|
||||||
.Images.AsNoTracking()
|
|
||||||
.Where(i => posterImageIds.Contains(i.Id))
|
|
||||||
.Select(i => new { i.Id, i.FileExtension })
|
|
||||||
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
|
|
||||||
var posterByShow = new Dictionary<Guid, (Guid ImageId, string AbsPath)>();
|
|
||||||
foreach (var p in posterShows)
|
|
||||||
if (
|
|
||||||
posterExtById.TryGetValue(p.ImageId, out var ext)
|
|
||||||
&& imageStore.ResolvePath(p.ImageId, ext) is { } abs
|
|
||||||
)
|
|
||||||
posterByShow[p.Id] = (p.ImageId, abs);
|
|
||||||
|
|
||||||
// Фон-картинки блоков (из реестра) — абсолютные пути по id.
|
// Кэш заставок (новейшие первыми) + статусы их ассетов: Ready/Pending/Processing переиспользуем,
|
||||||
var bgImageIds = channel
|
// Failed — рендерим заново.
|
||||||
.BumperTemplates.Where(t => t.BackgroundImageId != null)
|
|
||||||
.Select(t => t.BackgroundImageId!.Value)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
var bgExtById = await dbContext
|
|
||||||
.Images.AsNoTracking()
|
|
||||||
.Where(i => bgImageIds.Contains(i.Id))
|
|
||||||
.Select(i => new { i.Id, i.FileExtension })
|
|
||||||
.ToDictionaryAsync(i => i.Id, i => i.FileExtension, cancellationToken);
|
|
||||||
var bgByTemplate = new Dictionary<Guid, string>();
|
|
||||||
foreach (var t in channel.BumperTemplates)
|
|
||||||
if (
|
|
||||||
t.BackgroundImageId is { } bgId
|
|
||||||
&& bgExtById.TryGetValue(bgId, out var bgExt)
|
|
||||||
&& imageStore.ResolvePath(bgId, bgExt) is { } bgAbs
|
|
||||||
)
|
|
||||||
bgByTemplate[t.Id] = bgAbs;
|
|
||||||
|
|
||||||
// Кандидаты из кэша + статусы их ассетов (годятся только Ready — файлы могли удалить).
|
|
||||||
var cached = await dbContext
|
var cached = await dbContext
|
||||||
.BumperAssets.AsNoTracking()
|
.BumperAssets.AsNoTracking()
|
||||||
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
|
.Where(b => fromIds.Contains(b.FromShowId) && toIds.Contains(b.ToShowId))
|
||||||
|
.OrderByDescending(b => b.CreatedAt)
|
||||||
.Select(b => new
|
.Select(b => new
|
||||||
{
|
{
|
||||||
b.FromShowId,
|
b.FromShowId,
|
||||||
@@ -122,29 +89,28 @@ public sealed class ScheduleBumperResolver(
|
|||||||
b.MediaAssetId,
|
b.MediaAssetId,
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
|
var cachedAssetIds = cached.Select(c => c.MediaAssetId).Distinct().ToList();
|
||||||
var readyAssetIds = await dbContext
|
var statusById = await dbContext
|
||||||
.MediaAssets.AsNoTracking()
|
.MediaAssets.AsNoTracking()
|
||||||
.Where(a => cachedAssetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready)
|
.Where(a => cachedAssetIds.Contains(a.Id))
|
||||||
.Select(a => a.Id)
|
.Select(a => new { a.Id, a.Status })
|
||||||
.ToListAsync(cancellationToken);
|
.ToDictionaryAsync(a => a.Id, a => a.Status, cancellationToken);
|
||||||
var readySet = readyAssetIds.ToHashSet();
|
|
||||||
|
|
||||||
foreach (var combo in combos)
|
foreach (var combo in combos)
|
||||||
{
|
{
|
||||||
|
if (result.ContainsKey(combo))
|
||||||
|
continue;
|
||||||
if (!variantsById.TryGetValue(combo.Variant, out var pair))
|
if (!variantsById.TryGetValue(combo.Variant, out var pair))
|
||||||
continue;
|
continue;
|
||||||
var (variant, template) = pair;
|
var (variant, template) = pair;
|
||||||
|
|
||||||
var fromName = showNames.GetValueOrDefault(combo.From, "…");
|
var fromName = showNames.GetValueOrDefault(combo.From, "…");
|
||||||
var toName = showNames.GetValueOrDefault(combo.To, "…");
|
var toName = showNames.GetValueOrDefault(combo.To, "…");
|
||||||
// Постер шоу-получателя как фон — только для «Сейчас/Далее» (свободный текст шоу не упоминает).
|
|
||||||
var usePoster = variant.Kind == BumperTextKind.NowNext;
|
var usePoster = variant.Kind == BumperTextKind.NowNext;
|
||||||
var poster = usePoster && posterByShow.TryGetValue(combo.To, out var pr) ? pr : default;
|
var posterToken =
|
||||||
var posterToken = poster.ImageId == Guid.Empty ? "-" : poster.ImageId.ToString();
|
usePoster && posterImageByShow.TryGetValue(combo.To, out var pid)
|
||||||
var posterAbs = poster.ImageId == Guid.Empty ? null : poster.AbsPath;
|
? pid.ToString()
|
||||||
var bgAbs = bgByTemplate.GetValueOrDefault(template.Id);
|
: "-";
|
||||||
var aligned = BumperDuration.Aligned(
|
var aligned = BumperDuration.Aligned(
|
||||||
BumperDuration.TemplateSeconds(template),
|
BumperDuration.TemplateSeconds(template),
|
||||||
_segmentSeconds
|
_segmentSeconds
|
||||||
@@ -159,134 +125,43 @@ public sealed class ScheduleBumperResolver(
|
|||||||
posterToken
|
posterToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Новейшая кэш-строка с этой сигнатурой (список упорядочен по CreatedAt DESC).
|
||||||
var hit = cached.FirstOrDefault(c =>
|
var hit = cached.FirstOrDefault(c =>
|
||||||
c.FromShowId == combo.From
|
c.FromShowId == combo.From && c.ToShowId == combo.To && c.Signature == signature
|
||||||
&& c.ToShowId == combo.To
|
|
||||||
&& c.Signature == signature
|
|
||||||
&& readySet.Contains(c.MediaAssetId)
|
|
||||||
);
|
);
|
||||||
if (hit is not null)
|
if (
|
||||||
|
hit is not null
|
||||||
|
&& statusById.TryGetValue(hit.MediaAssetId, out var status)
|
||||||
|
&& status != MediaAssetStatus.Failed
|
||||||
|
)
|
||||||
{
|
{
|
||||||
result[combo] = hit.MediaAssetId;
|
result[combo] = hit.MediaAssetId; // Ready / Pending / Processing — переиспользуем
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
// Нет кэша либо прошлый рендер провалился — создаём Pending-ассет + кэш-строку, ставим в очередь.
|
||||||
{
|
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
||||||
var assetId = await RenderAsync(
|
dbContext.MediaAssets.Add(asset);
|
||||||
channel,
|
dbContext.BumperAssets.Add(
|
||||||
template,
|
BumperAsset.Create(
|
||||||
variant,
|
channel.Id,
|
||||||
|
template.Id,
|
||||||
|
variant.Id,
|
||||||
combo.From,
|
combo.From,
|
||||||
combo.To,
|
combo.To,
|
||||||
fromName,
|
|
||||||
toName,
|
|
||||||
aligned,
|
|
||||||
signature,
|
signature,
|
||||||
posterAbs,
|
asset.Id
|
||||||
bgAbs,
|
)
|
||||||
cancellationToken
|
);
|
||||||
);
|
// Сигнал — лучший случай (строка станет видимой после коммита транзакции планировщика);
|
||||||
result[combo] = assetId;
|
// гарантия подхвата — периодический опрос БД фоновым рендерером.
|
||||||
}
|
renderQueue.Enqueue(asset.Id);
|
||||||
catch (Exception ex)
|
result[combo] = asset.Id;
|
||||||
{
|
|
||||||
logger.LogError(
|
|
||||||
ex,
|
|
||||||
"Не удалось отрендерить заставку {From} → {To}",
|
|
||||||
fromName,
|
|
||||||
toName
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Guid> RenderAsync(
|
|
||||||
Channel channel,
|
|
||||||
BumperTemplate template,
|
|
||||||
BumperTextVariant variant,
|
|
||||||
Guid fromShowId,
|
|
||||||
Guid toShowId,
|
|
||||||
string fromName,
|
|
||||||
string toName,
|
|
||||||
int alignedDurationSeconds,
|
|
||||||
string signature,
|
|
||||||
string? posterAbsolutePath,
|
|
||||||
string? backgroundAbsolutePath,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var asset = MediaAsset.RegisterGenerated($"Заставка: {fromName} → {toName}");
|
|
||||||
var render = await bumperRenderer.RenderAsync(
|
|
||||||
asset.Id,
|
|
||||||
BuildSpec(
|
|
||||||
channel,
|
|
||||||
template,
|
|
||||||
variant,
|
|
||||||
alignedDurationSeconds,
|
|
||||||
fromName,
|
|
||||||
toName,
|
|
||||||
posterAbsolutePath,
|
|
||||||
backgroundAbsolutePath
|
|
||||||
),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
asset.MarkReady(
|
|
||||||
render.Duration,
|
|
||||||
render.SegmentSeconds,
|
|
||||||
render.SegmentCount,
|
|
||||||
render.Width,
|
|
||||||
render.Height,
|
|
||||||
"h264",
|
|
||||||
"aac",
|
|
||||||
render.RelativePath
|
|
||||||
);
|
|
||||||
|
|
||||||
dbContext.MediaAssets.Add(asset);
|
|
||||||
dbContext.BumperAssets.Add(BumperAsset.Create(fromShowId, toShowId, signature, asset.Id));
|
|
||||||
return asset.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private BumperRenderSpec BuildSpec(
|
|
||||||
Channel channel,
|
|
||||||
BumperTemplate template,
|
|
||||||
BumperTextVariant variant,
|
|
||||||
int alignedDurationSeconds,
|
|
||||||
string fromName,
|
|
||||||
string toName,
|
|
||||||
string? posterAbsolutePath,
|
|
||||||
string? backgroundAbsolutePath
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var free = variant.Kind == BumperTextKind.Free;
|
|
||||||
return new BumperRenderSpec(
|
|
||||||
alignedDurationSeconds,
|
|
||||||
_bumper.Width,
|
|
||||||
_bumper.Height,
|
|
||||||
template.BackgroundColor,
|
|
||||||
template.BackgroundColor2,
|
|
||||||
template.AccentColor,
|
|
||||||
template.TextColor,
|
|
||||||
FontPath(channel.BumperFont),
|
|
||||||
free ? "" : variant.NowLabel,
|
|
||||||
free ? "" : fromName,
|
|
||||||
free ? "" : variant.NextLabel,
|
|
||||||
free ? "" : toName,
|
|
||||||
backgroundAbsolutePath,
|
|
||||||
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
|
||||||
posterAbsolutePath,
|
|
||||||
free,
|
|
||||||
variant.Line1,
|
|
||||||
variant.Line2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FontPath(BumperFont font) =>
|
|
||||||
font == BumperFont.Serif ? _bumper.FontFileSerif : _bumper.FontFileSans;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
|
/// Сигнатура рендера = хэш всех входов заставки: общие настройки канала (шрифт/подписи/версия
|
||||||
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
|
/// шаблона), оформление и файлы блока (цвета/фон/звук/ревизия), названия шоу и постер. Меняется —
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сигнал «появилась заставка на рендер» для фонового рендерера. Как и у медиа-конвейера, источник
|
||||||
|
/// истины — статус ассета в БД (рендерер берёт следующий Pending c Source=Generated), а очередь лишь
|
||||||
|
/// будит его без задержки; потеря сигнала при рестарте задачи не теряет — они подхватываются из БД.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBumperRenderQueue
|
||||||
|
{
|
||||||
|
/// <summary>Разбудить рендерер: появился ассет-заставка в статусе Pending.</summary>
|
||||||
|
void Enqueue(Guid assetId);
|
||||||
|
|
||||||
|
/// <summary>Ждать сигнала о новой работе (с дренажом накопленных).</summary>
|
||||||
|
ValueTask WaitAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -68,6 +68,8 @@ public interface IIdentityService
|
|||||||
string? search,
|
string? search,
|
||||||
Guid? roleId,
|
Guid? roleId,
|
||||||
bool? isBlocked,
|
bool? isBlocked,
|
||||||
|
string? sort,
|
||||||
|
bool desc,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ public sealed record ListMediaAssetsQuery(
|
|||||||
int Page,
|
int Page,
|
||||||
int PageSize,
|
int PageSize,
|
||||||
IReadOnlyList<MediaAssetStatus> Statuses,
|
IReadOnlyList<MediaAssetStatus> Statuses,
|
||||||
string? Search
|
string? Search,
|
||||||
|
string? Sort = null,
|
||||||
|
bool Desc = false
|
||||||
) : IQuery<PagedList<MediaAssetDto>>;
|
) : IQuery<PagedList<MediaAssetDto>>;
|
||||||
|
|||||||
@@ -30,9 +30,33 @@ public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext)
|
|||||||
|
|
||||||
var total = await q.CountAsync(cancellationToken);
|
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
|
// Маппинг в памяти: MediaAssetDto.From обращается к TimeSpan.TotalSeconds, который EF в SQL
|
||||||
// не переводит. Страница ограничена pageSize, поэтому материализация сущностей безопасна.
|
// не переводит. Страница ограничена pageSize, поэтому материализация сущностей безопасна.
|
||||||
var entities = await q.OrderByDescending(x => x.CreatedAt)
|
var entities = await ordered
|
||||||
|
.ThenBy(x => x.Id)
|
||||||
.Skip((query.Page - 1) * query.PageSize)
|
.Skip((query.Page - 1) * query.PageSize)
|
||||||
.Take(query.PageSize)
|
.Take(query.PageSize)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed record MediaAssetDto(
|
|||||||
string? VideoCodec,
|
string? VideoCodec,
|
||||||
string? AudioCodec,
|
string? AudioCodec,
|
||||||
string? ErrorMessage,
|
string? ErrorMessage,
|
||||||
|
double? ProcessingSeconds,
|
||||||
DateTimeOffset CreatedAt
|
DateTimeOffset CreatedAt
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -30,6 +31,7 @@ public sealed record MediaAssetDto(
|
|||||||
asset.VideoCodec,
|
asset.VideoCodec,
|
||||||
asset.AudioCodec,
|
asset.AudioCodec,
|
||||||
asset.ErrorMessage,
|
asset.ErrorMessage,
|
||||||
|
asset.ProcessingDuration?.TotalSeconds,
|
||||||
asset.CreatedAt
|
asset.CreatedAt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Media.Stats;
|
||||||
|
|
||||||
|
/// <summary>Сводка по обработке медиа: сколько сейчас в очереди/в обработке и среднее время обработки.</summary>
|
||||||
|
public sealed record GetMediaStatsQuery : IQuery<MediaStatsDto>;
|
||||||
|
|
||||||
|
/// <param name="Queued">Ассетов в статусе Pending (ждут обработки) сейчас.</param>
|
||||||
|
/// <param name="Processing">Ассетов в статусе Processing (обрабатываются) сейчас.</param>
|
||||||
|
/// <param name="AverageProcessingSeconds">Среднее время обработки по недавним завершённым (Ready), сек; null — если нет данных.</param>
|
||||||
|
public sealed record MediaStatsDto(int Queued, int Processing, double? AverageProcessingSeconds);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Media.Stats;
|
||||||
|
|
||||||
|
public sealed class GetMediaStatsQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<GetMediaStatsQuery, MediaStatsDto>
|
||||||
|
{
|
||||||
|
/// <summary>По скольким последним завершённым ассетам усредняем время обработки.</summary>
|
||||||
|
private const int AverageSample = 500;
|
||||||
|
|
||||||
|
public async Task<MediaStatsDto> Handle(
|
||||||
|
GetMediaStatsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Сгенерированные (ТВ-заставки) в статистику библиотеки не входят.
|
||||||
|
var assets = dbContext.MediaAssets.AsNoTracking().Where(x => x.Source != MediaSource.Generated);
|
||||||
|
|
||||||
|
var queued = await assets.CountAsync(x => x.Status == MediaAssetStatus.Pending, cancellationToken);
|
||||||
|
var processing = await assets.CountAsync(
|
||||||
|
x => x.Status == MediaAssetStatus.Processing,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
// Среднее — по недавним завершённым (ограничение выборки, чтобы не тянуть всю историю). EF не
|
||||||
|
// усредняет TimeSpan в SQL, поэтому берём длительности последних N и усредняем в памяти.
|
||||||
|
var recent = await assets
|
||||||
|
.Where(x => x.Status == MediaAssetStatus.Ready && x.ProcessingDuration != null)
|
||||||
|
.OrderByDescending(x => x.UpdatedAt)
|
||||||
|
.Select(x => x.ProcessingDuration!.Value)
|
||||||
|
.Take(AverageSample)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
double? average = recent.Count > 0 ? recent.Average(d => d.TotalSeconds) : null;
|
||||||
|
|
||||||
|
return new MediaStatsDto(queued, processing, average);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Кэш отрендеренной ТВ-заставки перехода. Один сгенерированный <see cref="MediaAssetId"/> на
|
/// Кэш ТВ-заставки перехода. Один сгенерированный <see cref="MediaAssetId"/> на уникальную комбинацию
|
||||||
/// уникальную комбинацию (<see cref="FromShowId"/> → <see cref="ToShowId"/>) при данной
|
/// (<see cref="FromShowId"/> → <see cref="ToShowId"/>) при данной <see cref="Signature"/> (хэш названий
|
||||||
/// <see cref="Signature"/> (хэш названий шоу и версии шаблона). Переиспользуется между днями и
|
/// шоу и версии шаблона). Ассет создаётся в статусе Pending и рендерится ffmpeg'ом асинхронно фоновым
|
||||||
/// каналами; при смене названий/шаблона <see cref="Signature"/> меняется и рендерится новый ассет.
|
/// сервисом — поэтому здесь же храним, ЧЕМ его рендерить (<see cref="ChannelId"/>/<see cref="TemplateId"/>/
|
||||||
|
/// <see cref="VariantId"/>), чтобы фоновый рендерер восстановил спецификацию без участия планировщика.
|
||||||
|
/// Переиспользуется между днями и каналами; при смене названий/шаблона сигнатура меняется — новый ассет.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BumperAsset
|
public class BumperAsset
|
||||||
{
|
{
|
||||||
@@ -12,10 +14,15 @@ public class BumperAsset
|
|||||||
public Guid FromShowId { get; private set; }
|
public Guid FromShowId { get; private set; }
|
||||||
public Guid ToShowId { get; private set; }
|
public Guid ToShowId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Канал/блок/подблок, по которым фоновый рендерер восстановит спецификацию заставки.</summary>
|
||||||
|
public Guid ChannelId { get; private set; }
|
||||||
|
public Guid TemplateId { get; private set; }
|
||||||
|
public Guid VariantId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Хэш входных данных рендера (названия «из/в» + версия шаблона).</summary>
|
/// <summary>Хэш входных данных рендера (названия «из/в» + версия шаблона).</summary>
|
||||||
public string Signature { get; private set; } = string.Empty;
|
public string Signature { get; private set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Сгенерированный ассет-заставка (нарезан в assets/{id}, статус Ready).</summary>
|
/// <summary>Сгенерированный ассет-заставка (нарезается в assets/{id} фоновым рендерером).</summary>
|
||||||
public Guid MediaAssetId { get; private set; }
|
public Guid MediaAssetId { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
@@ -23,6 +30,9 @@ public class BumperAsset
|
|||||||
private BumperAsset() { }
|
private BumperAsset() { }
|
||||||
|
|
||||||
public static BumperAsset Create(
|
public static BumperAsset Create(
|
||||||
|
Guid channelId,
|
||||||
|
Guid templateId,
|
||||||
|
Guid variantId,
|
||||||
Guid fromShowId,
|
Guid fromShowId,
|
||||||
Guid toShowId,
|
Guid toShowId,
|
||||||
string signature,
|
string signature,
|
||||||
@@ -31,6 +41,9 @@ public class BumperAsset
|
|||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
ChannelId = channelId,
|
||||||
|
TemplateId = templateId,
|
||||||
|
VariantId = variantId,
|
||||||
FromShowId = fromShowId,
|
FromShowId = fromShowId,
|
||||||
ToShowId = toShowId,
|
ToShowId = toShowId,
|
||||||
Signature = signature,
|
Signature = signature,
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ public class MediaAsset
|
|||||||
/// <summary>Текст ошибки, если <see cref="Status"/> == <see cref="MediaAssetStatus.Failed"/>.</summary>
|
/// <summary>Текст ошибки, если <see cref="Status"/> == <see cref="MediaAssetStatus.Failed"/>.</summary>
|
||||||
public string? ErrorMessage { get; private set; }
|
public string? ErrorMessage { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Когда началась обработка (переход в Processing) — для расчёта её длительности.</summary>
|
||||||
|
public DateTimeOffset? ProcessingStartedAt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Сколько заняла обработка завершённого ассета (Ready) = момент готовности − старт обработки.</summary>
|
||||||
|
public TimeSpan? ProcessingDuration { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
public DateTimeOffset UpdatedAt { get; private set; }
|
public DateTimeOffset UpdatedAt { get; private set; }
|
||||||
|
|
||||||
@@ -56,8 +62,9 @@ public class MediaAsset
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ассет, сгенерированный системой (ТВ-заставка). Нарезку делает не общий пайплайн обработки, а
|
/// Ассет, сгенерированный системой (ТВ-заставка). Создаётся в <see cref="MediaAssetStatus.Pending"/>
|
||||||
/// специализированный рендерер, поэтому сразу помечаем <see cref="MarkReady"/> после создания.
|
/// и нарезается не общим пайплайном обработки, а специализированным фоновым рендерером
|
||||||
|
/// (BumperRenderBackgroundService), который по готовности вызывает <see cref="MarkReady"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static MediaAsset RegisterGenerated(string displayName)
|
public static MediaAsset RegisterGenerated(string displayName)
|
||||||
{
|
{
|
||||||
@@ -78,6 +85,8 @@ public class MediaAsset
|
|||||||
{
|
{
|
||||||
Status = MediaAssetStatus.Processing;
|
Status = MediaAssetStatus.Processing;
|
||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
|
ProcessingStartedAt = DateTimeOffset.UtcNow;
|
||||||
|
ProcessingDuration = null;
|
||||||
Touch();
|
Touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +111,11 @@ public class MediaAsset
|
|||||||
AudioCodec = audioCodec;
|
AudioCodec = audioCodec;
|
||||||
RelativePath = relativePath;
|
RelativePath = relativePath;
|
||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
|
if (ProcessingStartedAt is { } startedAt)
|
||||||
|
{
|
||||||
|
var elapsed = DateTimeOffset.UtcNow - startedAt;
|
||||||
|
ProcessingDuration = elapsed > TimeSpan.Zero ? elapsed : TimeSpan.Zero;
|
||||||
|
}
|
||||||
Touch();
|
Touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +131,7 @@ public class MediaAsset
|
|||||||
{
|
{
|
||||||
Status = MediaAssetStatus.Pending;
|
Status = MediaAssetStatus.Pending;
|
||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
|
ProcessingStartedAt = null;
|
||||||
Touch();
|
Touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,8 +158,10 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
|
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
|
||||||
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
|
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
|
||||||
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
|
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
|
||||||
|
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
|
||||||
|
|
||||||
services.AddHostedService<MediaProcessingBackgroundService>();
|
services.AddHostedService<MediaProcessingBackgroundService>();
|
||||||
services.AddHostedService<InboxScannerBackgroundService>();
|
services.AddHostedService<InboxScannerBackgroundService>();
|
||||||
|
services.AddHostedService<BumperRenderBackgroundService>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ internal sealed class IdentityService(
|
|||||||
string? search,
|
string? search,
|
||||||
Guid? roleId,
|
Guid? roleId,
|
||||||
bool? isBlocked,
|
bool? isBlocked,
|
||||||
|
string? sort,
|
||||||
|
bool desc,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -225,8 +227,25 @@ internal sealed class IdentityService(
|
|||||||
|
|
||||||
var total = await query.CountAsync(cancellationToken);
|
var total = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
var items = await query
|
var ordered = (sort?.ToLowerInvariant()) switch
|
||||||
.OrderByDescending(x => x.user.CreatedAt)
|
{
|
||||||
|
"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)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(x => new UserSummaryDto(
|
.Select(x => new UserSummaryDto(
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
using TeleWave.Application.Broadcast.Scheduling;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Streaming;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Асинхронно рендерит ТВ-заставки расписания: планировщик лишь создаёт ассет (Source=Generated) в
|
||||||
|
/// статусе Pending и кэш-строку <see cref="Domain.Broadcast.BumperAsset"/>, а сам ffmpeg крутится здесь,
|
||||||
|
/// вне тика планировщика и его транзакции. Источник истины — статус в БД (последовательно берём
|
||||||
|
/// следующий Pending c Source=Generated, помечаем Processing), поэтому рестарт/краш ничего не теряет
|
||||||
|
/// (прерванные Processing сбрасываются в Pending на старте). До готовности ассета плейлист отдаёт филлер.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BumperRenderBackgroundService(
|
||||||
|
IBumperRenderQueue queue,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IBumperRenderer renderer,
|
||||||
|
IBumperTemplateStorage bumperStorage,
|
||||||
|
IImageStore imageStore,
|
||||||
|
IOptions<BumperOptions> bumperOptions,
|
||||||
|
IOptions<StreamingOptions> streamingOptions,
|
||||||
|
ILogger<BumperRenderBackgroundService> logger
|
||||||
|
) : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan IdlePoll = TimeSpan.FromSeconds(30);
|
||||||
|
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||||
|
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await ResetInterruptedAsync(stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Разбираем всю накопившуюся работу из БД.
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var assetId = await ClaimNextAsync(stoppingToken);
|
||||||
|
if (assetId is not { } id)
|
||||||
|
break;
|
||||||
|
await RenderClaimedAsync(id, stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var wake = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||||
|
wake.CancelAfter(IdlePoll);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await queue.WaitAsync(wake.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Тайм-аут опроса — просто перепроверяем БД.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Ошибка цикла рендера заставок");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Сброс прерванных рестартом заставок (Generated Processing → Pending) на старте.</summary>
|
||||||
|
private async Task ResetInterruptedAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||||
|
|
||||||
|
var interrupted = await db
|
||||||
|
.MediaAssets.Where(x =>
|
||||||
|
x.Status == MediaAssetStatus.Processing && x.Source == MediaSource.Generated
|
||||||
|
)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (interrupted.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var asset in interrupted)
|
||||||
|
asset.ResetToPending();
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Атомарно захватывает самую раннюю Pending-заставку (Generated): Pending → Processing.</summary>
|
||||||
|
private async Task<Guid?> ClaimNextAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||||
|
|
||||||
|
var asset = await db
|
||||||
|
.MediaAssets.Where(x =>
|
||||||
|
x.Status == MediaAssetStatus.Pending && x.Source == MediaSource.Generated
|
||||||
|
)
|
||||||
|
.OrderBy(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (asset is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
asset.MarkProcessing();
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return asset.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RenderClaimedAsync(Guid assetId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||||
|
|
||||||
|
var spec = await BuildSpecAsync(db, assetId, cancellationToken);
|
||||||
|
if (spec is null)
|
||||||
|
{
|
||||||
|
await FailAsync(assetId, "Не удалось восстановить спецификацию заставки", cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var render = await renderer.RenderAsync(assetId, spec, cancellationToken);
|
||||||
|
|
||||||
|
var asset = await db.MediaAssets.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == assetId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (asset is null)
|
||||||
|
return;
|
||||||
|
asset.MarkReady(
|
||||||
|
render.Duration,
|
||||||
|
render.SegmentSeconds,
|
||||||
|
render.SegmentCount,
|
||||||
|
render.Width,
|
||||||
|
render.Height,
|
||||||
|
"h264",
|
||||||
|
"aac",
|
||||||
|
render.RelativePath
|
||||||
|
);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Рендер заставки {AssetId} провалился", assetId);
|
||||||
|
await FailAsync(assetId, ex.Message, CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки (канал/блок/подблок).</summary>
|
||||||
|
private async Task<BumperRenderSpec?> BuildSpecAsync(
|
||||||
|
IAppDbContext db,
|
||||||
|
Guid assetId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var cache = await db
|
||||||
|
.BumperAssets.AsNoTracking()
|
||||||
|
.Where(b => b.MediaAssetId == assetId)
|
||||||
|
.OrderByDescending(b => b.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (cache is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var channel = await db
|
||||||
|
.Channels.AsNoTracking()
|
||||||
|
.Include(c => c.BumperTemplates)
|
||||||
|
.ThenInclude(t => t.Variants)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == cache.ChannelId, cancellationToken);
|
||||||
|
var template = channel?.BumperTemplates.FirstOrDefault(t => t.Id == cache.TemplateId);
|
||||||
|
var variant = template?.Variants.FirstOrDefault(v => v.Id == cache.VariantId);
|
||||||
|
if (channel is null || template is null || variant is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var names = await db
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
|
||||||
|
.Select(s => new { s.Id, s.Name })
|
||||||
|
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||||
|
var fromName = names.GetValueOrDefault(cache.FromShowId, "…");
|
||||||
|
var toName = names.GetValueOrDefault(cache.ToShowId, "…");
|
||||||
|
|
||||||
|
// Постер шоу-получателя как фон — только для «Сейчас/Далее».
|
||||||
|
string? posterPath = null;
|
||||||
|
if (variant.Kind == Domain.Broadcast.BumperTextKind.NowNext)
|
||||||
|
posterPath = await ResolveShowPosterAsync(db, cache.ToShowId, cancellationToken);
|
||||||
|
|
||||||
|
var bgPath = await ResolveTemplateBackgroundAsync(db, template.BackgroundImageId, cancellationToken);
|
||||||
|
var aligned = BumperDuration.Aligned(
|
||||||
|
BumperDuration.TemplateSeconds(template),
|
||||||
|
_segmentSeconds
|
||||||
|
);
|
||||||
|
var audioPath = bumperStorage.AudioPath(template.Id, template.AudioExtension);
|
||||||
|
|
||||||
|
return BumperSpecFactory.Build(
|
||||||
|
_bumper,
|
||||||
|
channel.BumperFont,
|
||||||
|
template,
|
||||||
|
variant,
|
||||||
|
aligned,
|
||||||
|
fromName,
|
||||||
|
toName,
|
||||||
|
audioPath,
|
||||||
|
posterPath,
|
||||||
|
bgPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ResolveShowPosterAsync(
|
||||||
|
IAppDbContext db,
|
||||||
|
Guid showId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var posterImageId = await db
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => s.Id == showId && s.PosterImageId != null)
|
||||||
|
.Select(s => s.PosterImageId!.Value)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (posterImageId == Guid.Empty)
|
||||||
|
return null;
|
||||||
|
return await ResolveImagePathAsync(db, posterImageId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ResolveTemplateBackgroundAsync(
|
||||||
|
IAppDbContext db,
|
||||||
|
Guid? backgroundImageId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (backgroundImageId is not { } bgId)
|
||||||
|
return null;
|
||||||
|
return await ResolveImagePathAsync(db, bgId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ResolveImagePathAsync(
|
||||||
|
IAppDbContext db,
|
||||||
|
Guid imageId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var ext = await db
|
||||||
|
.Images.AsNoTracking()
|
||||||
|
.Where(i => i.Id == imageId)
|
||||||
|
.Select(i => i.FileExtension)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
return ext is null ? null : imageStore.ResolvePath(imageId, ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FailAsync(Guid assetId, string error, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||||
|
var asset = await db.MediaAssets.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == assetId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (asset is null)
|
||||||
|
return;
|
||||||
|
asset.MarkFailed(error);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
|
/// <summary>Сигнальная очередь-будильник поверх Channel (id ассета — лишь сигнал; работу берём из БД).</summary>
|
||||||
|
public sealed class BumperRenderQueue : IBumperRenderQueue
|
||||||
|
{
|
||||||
|
private readonly Channel<Guid> _channel = Channel.CreateUnbounded<Guid>(
|
||||||
|
new UnboundedChannelOptions { SingleReader = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
public void Enqueue(Guid assetId) => _channel.Writer.TryWrite(assetId);
|
||||||
|
|
||||||
|
public async ValueTask WaitAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _channel.Reader.ReadAsync(cancellationToken);
|
||||||
|
while (_channel.Reader.TryRead(out _)) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,7 +130,9 @@ public sealed class MediaProcessingBackgroundService(
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||||
|
|
||||||
var interrupted = await db
|
var interrupted = await db
|
||||||
.MediaAssets.Where(x => x.Status == MediaAssetStatus.Processing)
|
.MediaAssets.Where(x =>
|
||||||
|
x.Status == MediaAssetStatus.Processing && x.Source != MediaSource.Generated
|
||||||
|
)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
if (interrupted.Count == 0)
|
if (interrupted.Count == 0)
|
||||||
return;
|
return;
|
||||||
@@ -152,8 +154,11 @@ public sealed class MediaProcessingBackgroundService(
|
|||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||||
|
|
||||||
|
// Generated-ассеты (ТВ-заставки) обслуживает BumperRenderBackgroundService — их не берём.
|
||||||
var asset = await db
|
var asset = await db
|
||||||
.MediaAssets.Where(x => x.Status == MediaAssetStatus.Pending)
|
.MediaAssets.Where(x =>
|
||||||
|
x.Status == MediaAssetStatus.Pending && x.Source != MediaSource.Generated
|
||||||
|
)
|
||||||
.OrderBy(x => x.CreatedAt)
|
.OrderBy(x => x.CreatedAt)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (asset is null)
|
if (asset is null)
|
||||||
|
|||||||
Generated
+1017
File diff suppressed because it is too large
Load Diff
+52
@@ -0,0 +1,52 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class BumperAssetRenderContext : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ChannelId",
|
||||||
|
table: "BumperAssets",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "TemplateId",
|
||||||
|
table: "BumperAssets",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "VariantId",
|
||||||
|
table: "BumperAssets",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ChannelId",
|
||||||
|
table: "BumperAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TemplateId",
|
||||||
|
table: "BumperAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "VariantId",
|
||||||
|
table: "BumperAssets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1023
File diff suppressed because it is too large
Load Diff
+39
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MediaProcessingTiming : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<TimeSpan>(
|
||||||
|
name: "ProcessingDuration",
|
||||||
|
table: "MediaAssets",
|
||||||
|
type: "interval",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "ProcessingStartedAt",
|
||||||
|
table: "MediaAssets",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProcessingDuration",
|
||||||
|
table: "MediaAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProcessingStartedAt",
|
||||||
|
table: "MediaAssets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,9 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -178,9 +181,15 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)");
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TemplateId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<Guid>("ToShowId")
|
b.Property<Guid>("ToShowId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("VariantId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("FromShowId", "ToShowId", "Signature");
|
b.HasIndex("FromShowId", "ToShowId", "Signature");
|
||||||
@@ -694,6 +703,12 @@ namespace TeleWave.Infrastructure.Migrations
|
|||||||
.HasMaxLength(512)
|
.HasMaxLength(512)
|
||||||
.HasColumnType("character varying(512)");
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("ProcessingDuration")
|
||||||
|
.HasColumnType("interval");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ProcessingStartedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("RelativePath")
|
b.Property<string>("RelativePath")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using TeleWave.Application.Media.Stats;
|
||||||
|
using TeleWave.Application.Tests.Support;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Tests.Media;
|
||||||
|
|
||||||
|
public class MediaStatsTests
|
||||||
|
{
|
||||||
|
private static MediaAsset Pending(string name) =>
|
||||||
|
MediaAsset.Register(name, ".mkv", MediaSource.Upload);
|
||||||
|
|
||||||
|
private static MediaAsset Processing(string name)
|
||||||
|
{
|
||||||
|
var a = Pending(name);
|
||||||
|
a.MarkProcessing();
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MediaAsset Ready(string name)
|
||||||
|
{
|
||||||
|
var a = Pending(name);
|
||||||
|
a.MarkProcessing();
|
||||||
|
a.MarkReady(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x");
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stats_CountsQueuedAndProcessing_AndAveragesReady_ExcludingGenerated()
|
||||||
|
{
|
||||||
|
var fixture = new TestDb();
|
||||||
|
await using (var seed = fixture.New())
|
||||||
|
{
|
||||||
|
seed.MediaAssets.AddRange(
|
||||||
|
Pending("a.mkv"),
|
||||||
|
Pending("b.mkv"),
|
||||||
|
Processing("c.mkv"),
|
||||||
|
Ready("d.mkv"),
|
||||||
|
MediaAsset.RegisterGenerated("Заставка") // Generated — не входит в статистику
|
||||||
|
);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = fixture.New();
|
||||||
|
var stats = await new GetMediaStatsQueryHandler(db).Handle(
|
||||||
|
new GetMediaStatsQuery(),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(2, stats.Queued); // два Pending (Generated исключён)
|
||||||
|
Assert.Equal(1, stats.Processing);
|
||||||
|
Assert.NotNull(stats.AverageProcessingSeconds);
|
||||||
|
Assert.True(stats.AverageProcessingSeconds >= 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,12 +25,18 @@ public class DomainRecordFactoryTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void BumperAsset_Create_StoresPair()
|
public void BumperAsset_Create_StoresPair()
|
||||||
{
|
{
|
||||||
|
var channel = Guid.NewGuid();
|
||||||
|
var template = Guid.NewGuid();
|
||||||
|
var variant = Guid.NewGuid();
|
||||||
var from = Guid.NewGuid();
|
var from = Guid.NewGuid();
|
||||||
var to = Guid.NewGuid();
|
var to = Guid.NewGuid();
|
||||||
var asset = Guid.NewGuid();
|
var asset = Guid.NewGuid();
|
||||||
|
|
||||||
var b = BumperAsset.Create(from, to, "sig", asset);
|
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
|
||||||
|
|
||||||
|
Assert.Equal(channel, b.ChannelId);
|
||||||
|
Assert.Equal(template, b.TemplateId);
|
||||||
|
Assert.Equal(variant, b.VariantId);
|
||||||
Assert.Equal(from, b.FromShowId);
|
Assert.Equal(from, b.FromShowId);
|
||||||
Assert.Equal(to, b.ToShowId);
|
Assert.Equal(to, b.ToShowId);
|
||||||
Assert.Equal("sig", b.Signature);
|
Assert.Equal("sig", b.Signature);
|
||||||
|
|||||||
@@ -55,6 +55,31 @@ public class MediaAssetTests
|
|||||||
Assert.Null(asset.ErrorMessage);
|
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]
|
[Fact]
|
||||||
public void MarkFailed_StoresError()
|
public void MarkFailed_StoresError()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Infrastructure.Persistence;
|
||||||
|
using Testcontainers.PostgreSql;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Integration.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
||||||
|
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
||||||
|
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PostgresFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
||||||
|
.WithImage("postgres:16-alpine")
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
||||||
|
public bool Available { get; private set; }
|
||||||
|
|
||||||
|
public string ConnectionString => _container.GetConnectionString();
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _container.StartAsync();
|
||||||
|
await using var db = CreateContext();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
Available = true;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
|
||||||
|
Available = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppDbContext CreateContext() =>
|
||||||
|
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
||||||
|
|
||||||
|
public async Task DisposeAsync() => await _container.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[CollectionDefinition("postgres")]
|
||||||
|
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
||||||
|
|
||||||
|
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
||||||
|
internal sealed class SequenceRandom(params int[] sequence) : Domain.Broadcast.Scheduling.IRandomSource
|
||||||
|
{
|
||||||
|
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
|
||||||
|
private int _i;
|
||||||
|
|
||||||
|
public int Next(int maxExclusive)
|
||||||
|
{
|
||||||
|
if (maxExclusive <= 0)
|
||||||
|
return 0;
|
||||||
|
var value = _sequence[_i++ % _sequence.Length];
|
||||||
|
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
using TeleWave.Application.Broadcast.Scheduling;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Streaming;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Broadcast.Scheduling;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
using TeleWave.Infrastructure.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Integration.Tests;
|
||||||
|
|
||||||
|
[Collection("postgres")]
|
||||||
|
public sealed class ScheduleGeneratorIntegrationTests(PostgresFixture fixture)
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset Now = new(2026, 3, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
private const int Seg = 2;
|
||||||
|
|
||||||
|
private static MediaAsset ReadyAsset(string name, int seconds)
|
||||||
|
{
|
||||||
|
var a = MediaAsset.Register(name, ".mkv", MediaSource.Upload);
|
||||||
|
a.MarkReady(
|
||||||
|
TimeSpan.FromSeconds(seconds),
|
||||||
|
Seg,
|
||||||
|
seconds / Seg,
|
||||||
|
1920,
|
||||||
|
1080,
|
||||||
|
"h264",
|
||||||
|
"aac",
|
||||||
|
$"assets/{a.Id}"
|
||||||
|
);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScheduleGenerator NewGenerator(
|
||||||
|
AppDbContext db,
|
||||||
|
IRandomSource random,
|
||||||
|
IBumperRenderQueue queue
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var streaming = Options.Create(new StreamingOptions { SegmentSeconds = Seg });
|
||||||
|
var resolver = new ScheduleBumperResolver(
|
||||||
|
db,
|
||||||
|
queue,
|
||||||
|
Options.Create(new BumperOptions()),
|
||||||
|
streaming
|
||||||
|
);
|
||||||
|
return new ScheduleGenerator(
|
||||||
|
db,
|
||||||
|
random,
|
||||||
|
resolver,
|
||||||
|
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionHours = 24 }),
|
||||||
|
streaming
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingQueue : IBumperRenderQueue
|
||||||
|
{
|
||||||
|
public List<Guid> Enqueued { get; } = [];
|
||||||
|
|
||||||
|
public void Enqueue(Guid assetId) => Enqueued.Add(assetId);
|
||||||
|
|
||||||
|
public ValueTask WaitAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task GenerateAsync_MaterializesProgramSchedule()
|
||||||
|
{
|
||||||
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
var channel = Channel.Create("Prog", $"prog-{Guid.NewGuid():N}", Now);
|
||||||
|
var show = Show.Create("Show A", ShowKind.Series);
|
||||||
|
var a1 = ReadyAsset("a1.mkv", 1200);
|
||||||
|
var a2 = ReadyAsset("a2.mkv", 1200);
|
||||||
|
show.AddEpisode(a1.Id);
|
||||||
|
show.AddEpisode(a2.Id);
|
||||||
|
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
|
||||||
|
|
||||||
|
await using (var seed = fixture.CreateContext())
|
||||||
|
{
|
||||||
|
seed.Shows.Add(show);
|
||||||
|
seed.MediaAssets.AddRange(a1, a2);
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
int added;
|
||||||
|
await using (var db = fixture.CreateContext())
|
||||||
|
{
|
||||||
|
added = await NewGenerator(db, new SequenceRandom(0), new RecordingQueue())
|
||||||
|
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(added > 0);
|
||||||
|
await using var verify = fixture.CreateContext();
|
||||||
|
var entries = await verify
|
||||||
|
.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(e => e.ChannelId == channel.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.NotEmpty(entries);
|
||||||
|
Assert.Contains(entries, e => e.Kind == ScheduleEntryKind.Program);
|
||||||
|
Assert.All(entries, e => Assert.True(e.EndsAtUtc <= Now.AddDays(1).AddSeconds(Seg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task GenerateAsync_CreatesPendingBumperAssets_ForShowTransitions()
|
||||||
|
{
|
||||||
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
// Канал с двумя шоу и включёнными заставками — на смене шоу планировщик ставит заставку;
|
||||||
|
// её ассет должен создаться в Pending (Generated) и попасть в очередь фонового рендера.
|
||||||
|
var channel = Channel.Create("Bump", $"bump-{Guid.NewGuid():N}", Now);
|
||||||
|
channel.UpdateSettings(channel.Name, true, AdInsertion.BetweenBlocks, 0, true, null);
|
||||||
|
var showA = Show.Create("A", ShowKind.Series);
|
||||||
|
var showB = Show.Create("B", ShowKind.Series);
|
||||||
|
var a1 = ReadyAsset("a1.mkv", 1200);
|
||||||
|
var b1 = ReadyAsset("b1.mkv", 1200);
|
||||||
|
showA.AddEpisode(a1.Id);
|
||||||
|
showB.AddEpisode(b1.Id);
|
||||||
|
channel.AddShow(showA.Id, 1, BlockMode.Count, 1);
|
||||||
|
channel.AddShow(showB.Id, 1, BlockMode.Count, 1);
|
||||||
|
|
||||||
|
await using (var seed = fixture.CreateContext())
|
||||||
|
{
|
||||||
|
seed.Shows.AddRange(showA, showB);
|
||||||
|
seed.MediaAssets.AddRange(a1, b1);
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var queue = new RecordingQueue();
|
||||||
|
await using (var db = fixture.CreateContext())
|
||||||
|
{
|
||||||
|
// Чередуем выбор шоу (0,1,0,1…), чтобы гарантированно были переходы «A→B».
|
||||||
|
await NewGenerator(db, new SequenceRandom(0, 1), queue)
|
||||||
|
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = fixture.CreateContext();
|
||||||
|
var bumperCache = await verify
|
||||||
|
.BumperAssets.AsNoTracking()
|
||||||
|
.Where(b => b.ChannelId == channel.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.NotEmpty(bumperCache);
|
||||||
|
|
||||||
|
var bumperAssetIds = bumperCache.Select(b => b.MediaAssetId).ToList();
|
||||||
|
var bumperAssets = await verify
|
||||||
|
.MediaAssets.AsNoTracking()
|
||||||
|
.Where(a => bumperAssetIds.Contains(a.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.All(
|
||||||
|
bumperAssets,
|
||||||
|
a =>
|
||||||
|
{
|
||||||
|
Assert.Equal(MediaSource.Generated, a.Source);
|
||||||
|
Assert.Equal(MediaAssetStatus.Pending, a.Status);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Assert.NotEmpty(queue.Enqueued);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task ConcurrentGenerate_SerializedByAdvisoryLock_NoOverlappingEntries()
|
||||||
|
{
|
||||||
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
var channel = Channel.Create("Race", $"race-{Guid.NewGuid():N}", Now);
|
||||||
|
var show = Show.Create("Show", ShowKind.Series);
|
||||||
|
var a1 = ReadyAsset("r1.mkv", 1200);
|
||||||
|
var a2 = ReadyAsset("r2.mkv", 1200);
|
||||||
|
show.AddEpisode(a1.Id);
|
||||||
|
show.AddEpisode(a2.Id);
|
||||||
|
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
|
||||||
|
|
||||||
|
await using (var seed = fixture.CreateContext())
|
||||||
|
{
|
||||||
|
seed.Shows.Add(show);
|
||||||
|
seed.MediaAssets.AddRange(a1, a2);
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Два параллельных прогона на РАЗНЫХ соединениях — advisory-lock должен их сериализовать,
|
||||||
|
// иначе оба дописали бы хвост от одной точки продолжения и записи бы перекрылись.
|
||||||
|
await using var db1 = fixture.CreateContext();
|
||||||
|
await using var db2 = fixture.CreateContext();
|
||||||
|
var g1 = NewGenerator(db1, new SequenceRandom(0), new RecordingQueue())
|
||||||
|
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||||
|
var g2 = NewGenerator(db2, new SequenceRandom(0), new RecordingQueue())
|
||||||
|
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||||
|
await Task.WhenAll(g1, g2);
|
||||||
|
|
||||||
|
await using var verify = fixture.CreateContext();
|
||||||
|
var entries = await verify
|
||||||
|
.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(e => e.ChannelId == channel.Id)
|
||||||
|
.OrderBy(e => e.StartsAtUtc)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Assert.NotEmpty(entries);
|
||||||
|
for (var i = 1; i < entries.Count; i++)
|
||||||
|
Assert.True(
|
||||||
|
entries[i].StartsAtUtc >= entries[i - 1].EndsAtUtc,
|
||||||
|
$"Записи перекрываются: [{entries[i - 1].StartsAtUtc:o}..{entries[i - 1].EndsAtUtc:o}] и [{entries[i].StartsAtUtc:o}..{entries[i].EndsAtUtc:o}]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="NSubstitute" />
|
||||||
|
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||||
|
<PackageReference Include="Xunit.SkippableFact" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\TeleWave.Application\TeleWave.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NSubstitute;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Maintenance.DeleteShowMedia;
|
||||||
|
using TeleWave.Application.Media.Register;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeleWave.Integration.Tests;
|
||||||
|
|
||||||
|
[Collection("postgres")]
|
||||||
|
public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
||||||
|
{
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task RegisterMediaAsset_RollsBack_WhenFilePromoteFails()
|
||||||
|
{
|
||||||
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
var fileName = $"clip-{Guid.NewGuid():N}.mkv";
|
||||||
|
var storage = Substitute.For<IMediaStorage>();
|
||||||
|
storage
|
||||||
|
.PromoteToOriginalAsync(
|
||||||
|
Arg.Any<MediaSource>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<Guid>(),
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<CancellationToken>()
|
||||||
|
)
|
||||||
|
.Returns(Task.FromException(new IOException("disk full")));
|
||||||
|
|
||||||
|
await using var db = fixture.CreateContext();
|
||||||
|
var handler = new RegisterMediaAssetCommandHandler(db, storage);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<IOException>(() =>
|
||||||
|
handler.Handle(
|
||||||
|
new RegisterMediaAssetCommand(fileName, MediaSource.Upload, fileName),
|
||||||
|
CancellationToken.None
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Перенос файла упал — регистрация должна откатиться, чтобы не осталось orphan-строки.
|
||||||
|
await using var verify = fixture.CreateContext();
|
||||||
|
var exists = await verify.MediaAssets.AnyAsync(a => a.OriginalFileName == fileName);
|
||||||
|
Assert.False(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task DeleteShowMedia_AtomicallyRemovesEntriesAssetsAndEpisodes()
|
||||||
|
{
|
||||||
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
var channel = Channel.Create("c", $"c-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
|
||||||
|
var show = Show.Create("Show", ShowKind.Series);
|
||||||
|
var asset = MediaAsset.Register("ep.mkv", ".mkv", MediaSource.Upload);
|
||||||
|
asset.MarkReady(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x");
|
||||||
|
show.AddEpisode(asset.Id);
|
||||||
|
var entry = ScheduleEntry.Program(
|
||||||
|
channel.Id,
|
||||||
|
asset.Id,
|
||||||
|
DateTimeOffset.UnixEpoch,
|
||||||
|
DateTimeOffset.UnixEpoch.AddMinutes(20),
|
||||||
|
show.Id,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
await using (var seed = fixture.CreateContext())
|
||||||
|
{
|
||||||
|
seed.Channels.Add(channel);
|
||||||
|
seed.Shows.Add(show);
|
||||||
|
seed.MediaAssets.Add(asset);
|
||||||
|
seed.ScheduleEntries.Add(entry);
|
||||||
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
var storage = Substitute.For<IMediaStorage>();
|
||||||
|
storage
|
||||||
|
.DeleteAssetArtifactsAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await using (var db = fixture.CreateContext())
|
||||||
|
{
|
||||||
|
var result = await new DeleteShowMediaCommandHandler(db, storage).Handle(
|
||||||
|
new DeleteShowMediaCommand(show.Id),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var verify = fixture.CreateContext();
|
||||||
|
Assert.False(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id));
|
||||||
|
Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.MediaAssetId == asset.Id));
|
||||||
|
var reloaded = await verify
|
||||||
|
.Shows.Include(s => s.Episodes)
|
||||||
|
.FirstAsync(s => s.Id == show.Id);
|
||||||
|
Assert.Empty(reloaded.Episodes);
|
||||||
|
await storage
|
||||||
|
.Received()
|
||||||
|
.DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Trash2, Upload } from 'lucide-react'
|
import { Trash2, Upload } from 'lucide-react'
|
||||||
import { useRef, useState } from 'react'
|
import { useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import type { ImageCategory } from '@/shared/api/types'
|
import type { ImageCategory } from '@/shared/api/types'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
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 { toast } from '@/shared/ui/toast-store'
|
||||||
import { deleteImage, imageUrl, listImages, uploadImage } from './api'
|
import { deleteImage, imageUrl, listImages, uploadImage } from './api'
|
||||||
|
|
||||||
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
|
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
|
||||||
|
|
||||||
|
type ImageOrder = 'new' | 'old' | 'az' | 'za'
|
||||||
|
|
||||||
export type ImagePick = { id: string; url: string }
|
export type ImagePick = { id: string; url: string }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,11 +38,30 @@ export function GalleryBrowser({
|
|||||||
const onError = (error: unknown) =>
|
const onError = (error: unknown) =>
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||||
|
|
||||||
|
const [order, setOrder] = useState<ImageOrder>('new')
|
||||||
|
|
||||||
const { data: images, isLoading } = useQuery({
|
const { data: images, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'images', active],
|
queryKey: ['admin', 'images', active],
|
||||||
queryFn: () => listImages(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 invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'images', active] })
|
||||||
|
|
||||||
const pick = (id: string) => {
|
const pick = (id: string) => {
|
||||||
@@ -87,6 +109,17 @@ export function GalleryBrowser({
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{onSelect ? t('admin.gallery.pickHint') : t('admin.gallery.browseHint')}
|
{onSelect ? t('admin.gallery.pickHint') : t('admin.gallery.browseHint')}
|
||||||
</span>
|
</span>
|
||||||
|
<Select value={order} onValueChange={(v) => setOrder(v as ImageOrder)}>
|
||||||
|
<SelectTrigger className="ml-auto h-8 w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="new">{t('admin.gallery.sort.newest')}</SelectItem>
|
||||||
|
<SelectItem value="old">{t('admin.gallery.sort.oldest')}</SelectItem>
|
||||||
|
<SelectItem value="az">{t('admin.gallery.sort.nameAsc')}</SelectItem>
|
||||||
|
<SelectItem value="za">{t('admin.gallery.sort.nameDesc')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<input
|
<input
|
||||||
ref={fileInput}
|
ref={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -114,7 +147,7 @@ export function GalleryBrowser({
|
|||||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||||
) : images && images.length > 0 ? (
|
) : images && images.length > 0 ? (
|
||||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
|
||||||
{images.map((img) => (
|
{sorted.map((img) => (
|
||||||
<div key={img.id} className="group relative">
|
<div key={img.id} className="group relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,17 +8,20 @@ import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
|||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Pager } from '@/shared/ui/pager'
|
import { Pager } from '@/shared/ui/pager'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { deleteMedia, listMedia } from './api'
|
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||||
import { useUploadStore } from './upload-store'
|
import { useUploadStore } from './upload-store'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
type MediaFilter = 'active' | 'all' | 'Ready' | 'Failed'
|
type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed'
|
||||||
|
|
||||||
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
|
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
|
||||||
active: ['Pending', 'Processing'],
|
active: ['Pending', 'Processing'],
|
||||||
|
Pending: ['Pending'],
|
||||||
|
Processing: ['Processing'],
|
||||||
all: [],
|
all: [],
|
||||||
Ready: ['Ready'],
|
Ready: ['Ready'],
|
||||||
Failed: ['Failed'],
|
Failed: ['Failed'],
|
||||||
@@ -48,12 +51,25 @@ export function MediaPanel() {
|
|||||||
const fileInputShow = useRef<HTMLInputElement>(null)
|
const fileInputShow = useRef<HTMLInputElement>(null)
|
||||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const { sort, toggle } = useTableSort('created', true)
|
||||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||||
const enqueue = useUploadStore((s) => s.enqueue)
|
const enqueue = useUploadStore((s) => s.enqueue)
|
||||||
|
|
||||||
|
const sortColumn = (key: string) => {
|
||||||
|
setPage(1)
|
||||||
|
toggle(key)
|
||||||
|
}
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'media', filter, page],
|
queryKey: ['admin', 'media', filter, page, sort.key, sort.desc],
|
||||||
queryFn: () => listMedia({ page, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }),
|
queryFn: () =>
|
||||||
|
listMedia({
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
statuses: filterStatuses[filter],
|
||||||
|
sort: sort.key,
|
||||||
|
desc: sort.desc,
|
||||||
|
}),
|
||||||
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
|
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
|
||||||
refetchInterval: (query) =>
|
refetchInterval: (query) =>
|
||||||
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
|
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
|
||||||
@@ -61,6 +77,14 @@ export function MediaPanel() {
|
|||||||
: false,
|
: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['admin', 'media', 'stats'],
|
||||||
|
queryFn: getMediaStats,
|
||||||
|
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
|
||||||
|
})
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
||||||
const onError = (error: unknown) =>
|
const onError = (error: unknown) =>
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||||
@@ -84,11 +108,32 @@ export function MediaPanel() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
|
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
|
||||||
|
<SelectItem value="Pending">{t('admin.media.statuses.Pending')}</SelectItem>
|
||||||
|
<SelectItem value="Processing">{t('admin.media.statuses.Processing')}</SelectItem>
|
||||||
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
|
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
|
||||||
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
|
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
|
||||||
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
|
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span title={t('admin.media.stats.queued')}>
|
||||||
|
{t('admin.media.stats.queuedShort')}:{' '}
|
||||||
|
<span className="text-foreground tabular-nums">{stats.queued}</span>
|
||||||
|
</span>
|
||||||
|
<span title={t('admin.media.stats.processing')}>
|
||||||
|
{t('admin.media.stats.processingShort')}:{' '}
|
||||||
|
<span className="text-foreground tabular-nums">{stats.processing}</span>
|
||||||
|
</span>
|
||||||
|
<span title={t('admin.media.stats.average')}>
|
||||||
|
{t('admin.media.stats.averageShort')}:{' '}
|
||||||
|
<span className="text-foreground tabular-nums">
|
||||||
|
{formatDuration(stats.averageProcessingSeconds)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -134,17 +179,43 @@ export function MediaPanel() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.media.name')}</th>
|
<SortHeader
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.media.status')}</th>
|
label={t('admin.media.name')}
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.media.duration')}</th>
|
sortKey="name"
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.media.resolution')}</th>
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.media.status')}
|
||||||
|
sortKey="status"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.media.duration')}
|
||||||
|
sortKey="duration"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.media.resolution')}
|
||||||
|
sortKey="resolution"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.media.processingTime')}
|
||||||
|
sortKey="processing"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
|
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||||
{t('common.loading')}
|
{t('common.loading')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -158,7 +229,7 @@ export function MediaPanel() {
|
|||||||
))}
|
))}
|
||||||
{data && data.items.length === 0 && !isLoading && (
|
{data && data.items.length === 0 && !isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
|
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||||
{t('admin.media.empty')}
|
{t('admin.media.empty')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -190,6 +261,9 @@ function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => v
|
|||||||
<td className="px-4 py-2 text-muted-foreground">
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
|
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground tabular-nums">
|
||||||
|
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
CreatedIdResponse,
|
CreatedIdResponse,
|
||||||
MediaAssetDto,
|
MediaAssetDto,
|
||||||
MediaAssetStatus,
|
MediaAssetStatus,
|
||||||
|
MediaStatsDto,
|
||||||
PagedList,
|
PagedList,
|
||||||
} from '@/shared/api/types'
|
} from '@/shared/api/types'
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ export type ListMediaParams = {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
statuses?: MediaAssetStatus[]
|
statuses?: MediaAssetStatus[]
|
||||||
search?: string
|
search?: string
|
||||||
|
sort?: string
|
||||||
|
desc?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMedia(params: ListMediaParams) {
|
export function listMedia(params: ListMediaParams) {
|
||||||
@@ -20,9 +23,15 @@ export function listMedia(params: ListMediaParams) {
|
|||||||
})
|
})
|
||||||
for (const status of params.statuses ?? []) query.append('status', status)
|
for (const status of params.statuses ?? []) query.append('status', status)
|
||||||
if (params.search) query.set('search', params.search)
|
if (params.search) query.set('search', params.search)
|
||||||
|
if (params.sort) query.set('sort', params.sort)
|
||||||
|
if (params.desc) query.set('desc', 'true')
|
||||||
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMediaStats() {
|
||||||
|
return apiRequest<MediaStatsDto>('/admin/media/stats')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
||||||
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@/shared/ui/dialog'
|
} from '@/shared/ui/dialog'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { createRole, deleteRole, listRoles, updateRole } from './api'
|
import { createRole, deleteRole, listRoles, updateRole } from './api'
|
||||||
|
|
||||||
@@ -27,6 +28,11 @@ export function RolesPanel() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: roles, isLoading } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
const { data: roles, isLoading } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
||||||
|
const { sort, toggle } = useTableSort('name', false)
|
||||||
|
const sortedRoles = sortRows(roles ?? [], sort, {
|
||||||
|
name: (r) => r.name.toLowerCase(),
|
||||||
|
system: (r) => r.isSystem,
|
||||||
|
})
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
|
||||||
|
|
||||||
@@ -97,8 +103,18 @@ export function RolesPanel() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.roles.name')}</th>
|
<SortHeader
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.roles.system')}</th>
|
label={t('admin.roles.name')}
|
||||||
|
sortKey="name"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={toggle}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.roles.system')}
|
||||||
|
sortKey="system"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={toggle}
|
||||||
|
/>
|
||||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -110,7 +126,7 @@ export function RolesPanel() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{roles?.map((role) => (
|
{sortedRoles.map((role) => (
|
||||||
<tr key={role.id} className="border-b border-border last:border-0">
|
<tr key={role.id} className="border-b border-border last:border-0">
|
||||||
<td className="px-4 py-2">{role.name}</td>
|
<td className="px-4 py-2">{role.name}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button } from '@/shared/ui/button'
|
|||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Pager } from '@/shared/ui/pager'
|
import { Pager } from '@/shared/ui/pager'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { createShow, deleteShow, listShows } from './api'
|
import { createShow, deleteShow, listShows } from './api'
|
||||||
|
|
||||||
@@ -22,19 +23,31 @@ export function ShowsPanel() {
|
|||||||
const [kind, setKind] = useState<ShowKind>('Series')
|
const [kind, setKind] = useState<ShowKind>('Series')
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const { sort, toggle } = useTableSort('name', false)
|
||||||
|
const sortColumn = (key: string) => {
|
||||||
|
setPage(1)
|
||||||
|
toggle(key)
|
||||||
|
}
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||||
|
|
||||||
// Список шоу обычно умещается в одну загрузку — фильтруем и листаем на клиенте (пикеры берут всё).
|
// Список шоу обычно умещается в одну загрузку — фильтруем, сортируем и листаем на клиенте.
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
const all = data ?? []
|
const all = data ?? []
|
||||||
if (!q) return all
|
const matched = q
|
||||||
return all.filter(
|
? all.filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q),
|
s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q),
|
||||||
)
|
)
|
||||||
}, [data, query])
|
: all
|
||||||
|
return sortRows(matched, sort, {
|
||||||
|
name: (s) => s.name.toLowerCase(),
|
||||||
|
kind: (s) => s.kind,
|
||||||
|
seasons: (s) => s.seasonCount,
|
||||||
|
episodes: (s) => s.episodeCount,
|
||||||
|
})
|
||||||
|
}, [data, query, sort])
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||||
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
|
||||||
@@ -102,10 +115,30 @@ export function ShowsPanel() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.shows.name')}</th>
|
<SortHeader
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.shows.kind')}</th>
|
label={t('admin.shows.name')}
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.shows.seasons')}</th>
|
sortKey="name"
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.shows.episodes')}</th>
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.shows.kind')}
|
||||||
|
sortKey="kind"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.shows.seasons')}
|
||||||
|
sortKey="seasons"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.shows.episodes')}
|
||||||
|
sortKey="episodes"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
import { Label } from '@/shared/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
|
||||||
import { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import type { UserSummaryDto } from '@/shared/api/types'
|
import type { UserSummaryDto } from '@/shared/api/types'
|
||||||
import { changeUserRole } from '@/features/admin/roles/api'
|
import { changeUserRole } from '@/features/admin/roles/api'
|
||||||
@@ -29,6 +30,11 @@ export function UsersPanel() {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [roleId, setRoleId] = useState<string>('')
|
const [roleId, setRoleId] = useState<string>('')
|
||||||
|
const { sort, toggle } = useTableSort('created', true)
|
||||||
|
const sortColumn = (key: string) => {
|
||||||
|
setPage(1)
|
||||||
|
toggle(key)
|
||||||
|
}
|
||||||
const [newUserName, setNewUserName] = useState('')
|
const [newUserName, setNewUserName] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [newRoleId, setNewRoleId] = useState('')
|
const [newRoleId, setNewRoleId] = useState('')
|
||||||
@@ -36,8 +42,16 @@ export function UsersPanel() {
|
|||||||
|
|
||||||
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'users', page, search, roleId],
|
queryKey: ['admin', 'users', page, search, roleId, sort.key, sort.desc],
|
||||||
queryFn: () => listUsers({ page, pageSize: PAGE_SIZE, search: search || undefined, roleId: roleId || undefined }),
|
queryFn: () =>
|
||||||
|
listUsers({
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
search: search || undefined,
|
||||||
|
roleId: roleId || undefined,
|
||||||
|
sort: sort.key,
|
||||||
|
desc: sort.desc,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||||
@@ -167,10 +181,30 @@ export function UsersPanel() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b border-border text-left text-muted-foreground">
|
<thead className="border-b border-border text-left text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.users.userName')}</th>
|
<SortHeader
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.users.role')}</th>
|
label={t('admin.users.userName')}
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.users.status')}</th>
|
sortKey="username"
|
||||||
<th className="px-4 py-2 font-medium">{t('admin.users.createdAt')}</th>
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.users.role')}
|
||||||
|
sortKey="role"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.users.status')}
|
||||||
|
sortKey="blocked"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
|
<SortHeader
|
||||||
|
label={t('admin.users.createdAt')}
|
||||||
|
sortKey="created"
|
||||||
|
sort={sort}
|
||||||
|
onToggle={sortColumn}
|
||||||
|
/>
|
||||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export type ListUsersParams = {
|
|||||||
search?: string
|
search?: string
|
||||||
roleId?: string
|
roleId?: string
|
||||||
isBlocked?: boolean
|
isBlocked?: boolean
|
||||||
|
sort?: string
|
||||||
|
desc?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listUsers(params: ListUsersParams) {
|
export function listUsers(params: ListUsersParams) {
|
||||||
@@ -17,6 +19,8 @@ export function listUsers(params: ListUsersParams) {
|
|||||||
if (params.search) query.set('search', params.search)
|
if (params.search) query.set('search', params.search)
|
||||||
if (params.roleId) query.set('roleId', params.roleId)
|
if (params.roleId) query.set('roleId', params.roleId)
|
||||||
if (params.isBlocked !== undefined) query.set('isBlocked', String(params.isBlocked))
|
if (params.isBlocked !== undefined) query.set('isBlocked', String(params.isBlocked))
|
||||||
|
if (params.sort) query.set('sort', params.sort)
|
||||||
|
if (params.desc) query.set('desc', 'true')
|
||||||
|
|
||||||
return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${query.toString()}`)
|
return apiRequest<PagedList<UserSummaryDto>>(`/admin/users?${query.toString()}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,9 +59,16 @@ export type MediaAssetDto = {
|
|||||||
videoCodec: string | null
|
videoCodec: string | null
|
||||||
audioCodec: string | null
|
audioCodec: string | null
|
||||||
errorMessage: string | null
|
errorMessage: string | null
|
||||||
|
processingSeconds: number | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MediaStatsDto = {
|
||||||
|
queued: number
|
||||||
|
processing: number
|
||||||
|
averageProcessingSeconds: number | null
|
||||||
|
}
|
||||||
|
|
||||||
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
||||||
export type ShowKind = 'Series' | 'Single'
|
export type ShowKind = 'Series' | 'Single'
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ const resources = {
|
|||||||
status: 'Статус',
|
status: 'Статус',
|
||||||
duration: 'Длительность',
|
duration: 'Длительность',
|
||||||
resolution: 'Разрешение',
|
resolution: 'Разрешение',
|
||||||
|
processingTime: 'Время обработки',
|
||||||
empty: 'Пока нет загруженных файлов',
|
empty: 'Пока нет загруженных файлов',
|
||||||
statuses: {
|
statuses: {
|
||||||
Pending: 'В очереди',
|
Pending: 'В очереди',
|
||||||
@@ -161,6 +162,14 @@ const resources = {
|
|||||||
Ready: 'Готов',
|
Ready: 'Готов',
|
||||||
Failed: 'Ошибка',
|
Failed: 'Ошибка',
|
||||||
},
|
},
|
||||||
|
stats: {
|
||||||
|
queued: 'Сейчас в очереди',
|
||||||
|
queuedShort: 'В очереди',
|
||||||
|
processing: 'Сейчас в обработке',
|
||||||
|
processingShort: 'В обработке',
|
||||||
|
average: 'Среднее время обработки (по недавним)',
|
||||||
|
averageShort: 'Ср. время',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
gallery: {
|
gallery: {
|
||||||
title: 'Галерея',
|
title: 'Галерея',
|
||||||
@@ -168,6 +177,12 @@ const resources = {
|
|||||||
empty: 'В этой категории пока нет изображений',
|
empty: 'В этой категории пока нет изображений',
|
||||||
pickHint: 'Выберите изображение или загрузите новое',
|
pickHint: 'Выберите изображение или загрузите новое',
|
||||||
browseHint: 'Все изображения приложения по категориям',
|
browseHint: 'Все изображения приложения по категориям',
|
||||||
|
sort: {
|
||||||
|
newest: 'Сначала новые',
|
||||||
|
oldest: 'Сначала старые',
|
||||||
|
nameAsc: 'Имя: А–Я',
|
||||||
|
nameDesc: 'Имя: Я–А',
|
||||||
|
},
|
||||||
categories: {
|
categories: {
|
||||||
Library: 'Библиотека',
|
Library: 'Библиотека',
|
||||||
ShowPoster: 'Постеры шоу',
|
ShowPoster: 'Постеры шоу',
|
||||||
@@ -516,6 +531,7 @@ const resources = {
|
|||||||
status: 'Status',
|
status: 'Status',
|
||||||
duration: 'Duration',
|
duration: 'Duration',
|
||||||
resolution: 'Resolution',
|
resolution: 'Resolution',
|
||||||
|
processingTime: 'Processing time',
|
||||||
empty: 'No uploaded files yet',
|
empty: 'No uploaded files yet',
|
||||||
statuses: {
|
statuses: {
|
||||||
Pending: 'Queued',
|
Pending: 'Queued',
|
||||||
@@ -523,6 +539,14 @@ const resources = {
|
|||||||
Ready: 'Ready',
|
Ready: 'Ready',
|
||||||
Failed: 'Failed',
|
Failed: 'Failed',
|
||||||
},
|
},
|
||||||
|
stats: {
|
||||||
|
queued: 'Currently queued',
|
||||||
|
queuedShort: 'Queued',
|
||||||
|
processing: 'Currently processing',
|
||||||
|
processingShort: 'Processing',
|
||||||
|
average: 'Average processing time (recent)',
|
||||||
|
averageShort: 'Avg time',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
gallery: {
|
gallery: {
|
||||||
title: 'Gallery',
|
title: 'Gallery',
|
||||||
@@ -530,6 +554,12 @@ const resources = {
|
|||||||
empty: 'No images in this category yet',
|
empty: 'No images in this category yet',
|
||||||
pickHint: 'Pick an image or upload a new one',
|
pickHint: 'Pick an image or upload a new one',
|
||||||
browseHint: 'All app images by category',
|
browseHint: 'All app images by category',
|
||||||
|
sort: {
|
||||||
|
newest: 'Newest first',
|
||||||
|
oldest: 'Oldest first',
|
||||||
|
nameAsc: 'Name: A–Z',
|
||||||
|
nameDesc: 'Name: Z–A',
|
||||||
|
},
|
||||||
categories: {
|
categories: {
|
||||||
Library: 'Library',
|
Library: 'Library',
|
||||||
ShowPoster: 'Show posters',
|
ShowPoster: 'Show posters',
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/shared/lib/cn'
|
||||||
|
|
||||||
|
export type SortState = { key: string; desc: boolean }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние сортировки таблицы. Клик по новому столбцу — по возрастанию; повторный клик по тому же —
|
||||||
|
* переключает направление. Для серверных списков `sort`/`desc` передаются в API (и в queryKey), для
|
||||||
|
* клиентских — в {@link sortRows}.
|
||||||
|
*/
|
||||||
|
export function useTableSort(defaultKey: string, defaultDesc = false) {
|
||||||
|
const [sort, setSort] = useState<SortState>({ key: defaultKey, desc: defaultDesc })
|
||||||
|
const toggle = (key: string) =>
|
||||||
|
setSort((s) => (s.key === key ? { key, desc: !s.desc } : { key, desc: false }))
|
||||||
|
return { sort, toggle }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заголовок-кнопка столбца со стрелкой сортировки. */
|
||||||
|
export function SortHeader({
|
||||||
|
label,
|
||||||
|
sortKey,
|
||||||
|
sort,
|
||||||
|
onToggle,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
sortKey: string
|
||||||
|
sort: SortState
|
||||||
|
onToggle: (key: string) => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const active = sort.key === sortKey
|
||||||
|
const Icon = !active ? ChevronsUpDown : sort.desc ? ArrowDown : ArrowUp
|
||||||
|
return (
|
||||||
|
<th className={cn('px-4 py-2 font-medium', className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(sortKey)}
|
||||||
|
aria-sort={active ? (sort.desc ? 'descending' : 'ascending') : 'none'}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 hover:text-foreground',
|
||||||
|
active && 'text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<Icon className={cn('h-3.5 w-3.5', !active && 'opacity-40')} />
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Comparable = string | number | boolean | null | undefined
|
||||||
|
|
||||||
|
/** Клиентская сортировка строк по выбранному ключу (для непагинированных списков). nulls — в конец. */
|
||||||
|
export function sortRows<T>(
|
||||||
|
rows: T[],
|
||||||
|
sort: SortState,
|
||||||
|
accessors: Record<string, (row: T) => Comparable>,
|
||||||
|
): T[] {
|
||||||
|
const accessor = accessors[sort.key]
|
||||||
|
if (!accessor) return rows
|
||||||
|
const dir = sort.desc ? -1 : 1
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const av = accessor(a)
|
||||||
|
const bv = accessor(b)
|
||||||
|
if (av == null && bv == null) return 0
|
||||||
|
if (av == null) return 1
|
||||||
|
if (bv == null) return -1
|
||||||
|
if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir
|
||||||
|
return (av < bv ? -1 : av > bv ? 1 : 0) * dir
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user