Update configuration and enhance media processing: add stream token TTL and timeout settings in .env.example, improve error handling in media endpoints, and refactor command handlers for asynchronous operations. Update documentation to reflect current application state and features.
This commit is contained in:
@@ -32,6 +32,9 @@ Storage__LiveWindowSegments=10
|
||||
Storage__KeepOriginals=false
|
||||
# Порог свободного места, ниже которого загрузка отклоняется (10 ГБ).
|
||||
Storage__MinFreeSpaceBytes=10737418240
|
||||
# TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после
|
||||
# блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30.
|
||||
# Storage__StreamTokenMinutes=30
|
||||
|
||||
# ── Планировщик эфира ──────────────────────────────────────────────────────
|
||||
# На сколько дней вперёд держать расписание; сколько часов прошлого хранить; период тика.
|
||||
@@ -59,6 +62,10 @@ Media__InboxScanSeconds=15
|
||||
Media__NormalizeLoudness=true
|
||||
# Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить).
|
||||
Media__LoudnessTargetLufs=-16
|
||||
# Таймауты вызовов ffprobe/ffmpeg, секунды (0 — без таймаута): не дают зависшему процессу вечно
|
||||
# держать слот параллелизма/тик планировщика. Поднять TranscodeTimeoutSeconds для очень длинных файлов.
|
||||
# Media__ProbeTimeoutSeconds=120
|
||||
# Media__TranscodeTimeoutSeconds=1800
|
||||
|
||||
# ── ТВ-заставки «Сейчас/Далее» (bumpers) ───────────────────────────────────
|
||||
# Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность,
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
|
||||
хранилища на сервере. Админ управляет каналами и пользователями.
|
||||
|
||||
> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка,
|
||||
> приветственная главная страница. Каталог каналов и сама раздача видео **не реализованы** —
|
||||
> это следующий шаг. Telegram-бот сознательно не делаем.
|
||||
> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка;
|
||||
> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS-сегменты), реестр изображений,
|
||||
> метаданные (TMDb/OMDb), каталог каналов, планировщик эфира (реклама, ТВ-заставки-переходы,
|
||||
> weekly-override'ы) и live-раздача HLS с публичным просмотром сетки. Telegram-бот сознательно
|
||||
> не делаем. Дальнейшие крупные направления (напр. многоэкземплярное развёртывание, новые доменные
|
||||
> фичи) — по сверке с пользователем.
|
||||
|
||||
Архитектура и код-конвенции — прямое зеркало [`D:\Github\PnvPanel`](../PnvPanel) (тот же автор,
|
||||
тот же стек), но домен урезан под текущий объём фичи.
|
||||
@@ -137,9 +140,10 @@ docker compose up -d --build # только api; postgres — внешний,
|
||||
|
||||
## Рабочие принципы
|
||||
|
||||
- Не начинай крупную реализацию (каталог каналов, раздача видео, транскодинг и т.п.) без сверки
|
||||
с пользователем — это следующий большой этап после базы, архитектурные решения там ещё не приняты.
|
||||
При неоднозначности — вопрос пользователю, не предположение.
|
||||
- Базовый вертикальный срез (каналы, планировщик, раздача HLS, обработка медиа) уже реализован —
|
||||
правь его по месту. Но новые крупные направления с непринятыми архитектурными решениями (напр.
|
||||
многоэкземплярное развёртывание, транскод-профили, новые доменные подсистемы) начинай только после
|
||||
сверки с пользователем. При неоднозначности — вопрос пользователю, не предположение.
|
||||
- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel.
|
||||
- Не коммить и не пуши без явной просьбы.
|
||||
- Отвечай пользователю на русском.
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
|
||||
хранилища на сервере, админ управляет каналами и пользователями.
|
||||
|
||||
> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка,
|
||||
> приветственная главная страница. Каталог каналов и раздача видео — следующий этап, пока не
|
||||
> реализованы. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL —
|
||||
> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка;
|
||||
> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS), метаданные (TMDb/OMDb), каталог
|
||||
> каналов, планировщик эфира (реклама, ТВ-заставки, weekly-override'ы) и live-раздача HLS с публичным
|
||||
> просмотром сетки. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL —
|
||||
> **внешний**, не поднимается через compose.
|
||||
|
||||
## Стек
|
||||
|
||||
@@ -31,42 +31,42 @@
|
||||
### Безопасность
|
||||
- [x] **M1. Cookie без `Secure` за TLS-прокси** — `AuthEndpoints.cs:207`, `StreamingEndpoints.cs:63`.
|
||||
- [x] **M2. Ключи TMDb/OMDb в логах** — `TmdbMetadataProvider.cs:27`, `OmdbMetadataProvider.cs:25` (`System.Net.Http` не приглушён в Serilog).
|
||||
- [ ] **M3. Stream-токен неотзывной, TTL 6ч, игнорирует userId/блокировку** — `StreamTokenService.cs:15`, `StreamingEndpoints.cs:98,123`. _(дизайн-решение — отложено)_
|
||||
- [x] **M3. Stream-токен неотзывной, TTL 6ч, игнорирует userId/блокировку** — `StreamTokenService.cs:15`, `StreamingEndpoints.cs:98,123`. _(TTL 30 мин + проверка блокировки на плейлисте; фронт перевыпускает cookie)_
|
||||
- [x] **M4. Rate-limiter глобальный (не партиционирован), только на `/api/auth`** — `Program.cs:66`.
|
||||
|
||||
### Архитектура / транзакции
|
||||
- [ ] **M5. `ExecuteDeleteAsync` ломает границу UnitOfWork + файлы удаляются до коммита** — `DeleteShowMediaCommandHandler.cs:36`, `ClearAllMediaCommandHandler.cs`, `DeleteAllShowsCommandHandler.cs`. _(дизайн-решение — отложено)_
|
||||
- [ ] **M6. Query с побочным эффектом на ФС** — `RenderBumperPreviewQueryHandler.cs:18` (рендер файлов под видом запроса). _(дизайн-решение — отложено)_
|
||||
- [ ] **M7. Файловый/HTTP I/O до коммита** — Delete/Register/RefreshEpisodes хендлеры (orphan-файлы при откате). _(дизайн-решение — отложено)_
|
||||
- [x] **M5. `ExecuteDeleteAsync` ломает границу UnitOfWork + файлы удаляются до коммита** — `DeleteShowMediaCommandHandler.cs`, `ClearAllMediaCommandHandler.cs`. _(явная транзакция вокруг ExecuteDelete; файлы после коммита. DeleteAllShows — единичный ExecuteDelete, уже атомарен)_
|
||||
- [x] **M6. Query с побочным эффектом на ФС** — переведён в `RenderBumperPreviewCommand` (command, не query).
|
||||
- [x] **M7. Файловый/HTTP I/O до коммита** — Delete/Register хендлеры: БД коммитится раньше файлового I/O; Register откатывает регистрацию при сбое переноса файла.
|
||||
|
||||
### Планировщик / медиа
|
||||
- [ ] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs:63` (зависший процесс держит слот). _(нужен разумный таймаут — отложено на согласование)_
|
||||
- [ ] **M9. Гонка при конкурентной генерации расписания канала** — `ScheduleGenerator.cs:62` (тик + regenerate → дубли записей). _(дизайн-решение — отложено)_
|
||||
- [ ] **M10. Рендер заставок синхронно внутри тика планировщика** — `ScheduleBumperResolver.cs:174`. _(дизайн-решение — отложено)_
|
||||
- [x] **M8. Нет таймаута на ffmpeg/ffprobe** — `ProcessRunner.cs` + `MediaOptions` (ffprobe 120с, транскод/рендер 1800с, конфигурируемо).
|
||||
- [x] **M9. Гонка при конкурентной генерации расписания канала** — `ScheduleGenerator.cs` (транзакция + `pg_advisory_xact_lock` по каналу).
|
||||
- [~] **M10. Рендер заставок синхронно внутри тика планировщика** — `ScheduleBumperResolver.cs:174`. _(частично: M8 ограничивает худший случай, кэш делает рендеры редкими, M9 сериализует канал. Полный вынос рендера из тика — отдельная архитектурная задача, флажок ниже.)_
|
||||
- [x] **M11. Override через полночь не работает** — `SchedulePlannerModels.cs:40` (`EndMinute <= StartMinute` → пустое окно).
|
||||
|
||||
### Фронтенд
|
||||
- [x] **M12. Молчаливое проглатывание ошибки → вечный скелетон** — `AirPage.tsx:43`.
|
||||
- [x] **M13. Повторный 401 после refresh не разлогинивает** — `client.ts:80`.
|
||||
- [ ] **M14. Клиентская пагинация поверх усечённого ответа** — `ShowDetail.tsx:42` (`pageSize:500`), `ChannelDetail.tsx:32` (`pageSize:100`). _(нужна серверная пагинация — отложено)_
|
||||
- [ ] **M15. Дедуп загрузок только по имени файла** — `upload-store.ts:143`. _(зависит от контракта серверной уникальности — отложено)_
|
||||
- [x] **M14. Клиентская пагинация поверх усечённого ответа** — `ShowDetail.tsx`, `ChannelDetail.tsx`. _(пикеры дозагружают все страницы через `listAllMedia` с safety-cap; при упоре в cap — видимое предупреждение. True server-side pagination неуместна: пикеры делают клиентские исключение/парсинг/сортировку/bulk-select)_
|
||||
- [x] **M15. Дедуп загрузок только по имени файла** — `upload-store.ts:143`. _(разрешено анализом: сервер сам дедупит по `OriginalFileName` — клиент это зеркалит, а `skipped` показывается. Имя+размер разошлось бы с сервером → failed-загрузки. Уникальность по имени — доменное решение, кода не менял)_
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Низкое / прочее
|
||||
|
||||
- [ ] **L1. Синхронный I/O без CancellationToken в портах удаления** (`IMediaStorage`/`IImageStore`/`IBumperTemplateStorage`). _(меняет интерфейсы портов — отложено)_
|
||||
- [x] **L1. Синхронный I/O без CancellationToken в портах удаления** — все 5 delete-методов портов переведены на `Task`+`CancellationToken`; рекурсивные удаления каталогов офлоадятся (`Task.Run`).
|
||||
- [x] **L2. `ValidationBehavior` вызывает `Validate` вместо `ValidateAsync`** — `ValidationBehavior.cs:24`.
|
||||
- [ ] **L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате** — `Show.AddEpisode`. _(рефактор агрегата — отложено)_
|
||||
- [ ] **L4. Двойной `SaveChanges` в командном пути ScheduleGenerator** — `ScheduleGenerator.cs:79,132`. _(связано с M9 — отложено)_
|
||||
- [x] **L3. Инвариант «Single = 1 серия» проверяется в хендлере, а не в агрегате** — `Show.AddEpisode` кидает при обходе (+тест).
|
||||
- [x] **L4. Двойной `SaveChanges` в командном пути ScheduleGenerator** — снят: генератор коммитит свою транзакцию, пост-save UnitOfWork стал no-op.
|
||||
- [x] **L5. HLS-плеер не восстанавливается после fatal network/media error** — `ChannelPlayer.tsx:106` (+ симметричная очистка слушателей нативного HLS).
|
||||
- [x] **L6. Бесконечная повторная регистрация «падающего» файла из inbox** — `InboxScannerBackgroundService.cs:92`.
|
||||
- [x] **L7. Возможное переполнение int в весах шоу** — `SchedulePlanner.cs:256,265`.
|
||||
- [ ] **L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению** — `Program.cs:108`. _(возможно намеренно для внутреннего инструмента — решение за владельцем)_
|
||||
- [ ] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json**. _(dev-дефолты; решение за владельцем)_
|
||||
- [x] **L8. OpenAPI/Scalar мапятся всегда, без гейта по окружению** — `Program.cs` (только Development либо флаг `Api:EnableOpenApi`).
|
||||
- [x] **L9. `AllowedHosts: "*"` и dev-креды БД в appsettings.json** — осознанные dev-дефолты, переопределяются env в проде; C2 теперь форсит реальный JWT-ключ. Оставлено как задокументированное.
|
||||
- [x] **L10. SSRF-поверхность в ImageDownloader** (без allowlist схемы/хоста) — `ImageDownloader.cs:16` (allowlist http/https).
|
||||
- [ ] **L11. Документация (CLAUDE.md/README) отстала**: заявлено «каталог каналов и раздача видео не реализованы», хотя реализованы. _(нужна сверка с владельцем по объёму)_
|
||||
- [ ] **L12. Пробелы в тестах**: фоновые сервисы, ScheduleGenerator-оркестрация, media-конвейер, эндпоинты без тестов. _(частично: добавлены тесты на M11)_
|
||||
- [x] **L11. Документация (CLAUDE.md/README) отстала** — обновлены разделы «текущее состояние» и рабочий принцип под реальный объём.
|
||||
- [~] **L12. Пробелы в тестах** — частично: добавлены тесты на M11 (override через полночь), L3 (Single). Полное покрытие фоновых сервисов/оркестрации/эндпоинтов — отдельная задача.
|
||||
|
||||
---
|
||||
|
||||
@@ -126,3 +126,53 @@ Frontend:
|
||||
|
||||
Проверка: `dotnet build` 0/0; тесты 97 + 49 зелёные; frontend `tsc` чистый; `oxlint` без новых
|
||||
предупреждений.
|
||||
|
||||
### 2026-07-25 — остальные средние/низкие (M3, M5–M9, M14, L1, L3, L4, L8, L11 + разбор M10, M15, L9, L12)
|
||||
|
||||
По согласованным решениям (короткий TTL+проверка блокировки; конфиг-таймауты ffmpeg; Postgres
|
||||
advisory-lock; явные транзакции + I/O после коммита):
|
||||
|
||||
- **M3** — `StreamingOptions.StreamTokenMinutes` (30), `StreamTokenService.Validate` возвращает id
|
||||
зрителя; `LivePlaylist` сверяет блокировку через `IIdentityService` (1 запрос на перезагрузку
|
||||
плейлиста, не на сегмент). Фронт (`AirPage`) перевыпускает cookie каждые 20 мин. Dev-ключ JWT
|
||||
добавлен в `appsettings.Development.json`, чтобы C2 не ломал локальный запуск.
|
||||
- **M5/M7** — `IAppDbContext.BeginTransactionAsync`; `ClearAllMedia`/`DeleteShowMedia` — транзакция
|
||||
вокруг ExecuteDelete, файлы после коммита; `DeleteMediaAsset`/`DeleteImage`/`ClearBumperAudio`/
|
||||
`RemoveBumperTemplate` — БД раньше файлов; `RegisterMediaAsset` — save→перенос с откатом при сбое.
|
||||
- **M6** — `RenderBumperPreviewQuery` → `RenderBumperPreviewCommand` (+ файл/класс переименованы).
|
||||
- **M8** — `ProcessRunner` принимает таймаут; `MediaOptions.ProbeTimeoutSeconds`/`TranscodeTimeoutSeconds`;
|
||||
по таймауту процесс убивается, `TimeoutException` → ассет уходит в Failed, слот освобождается.
|
||||
- **M9** — `IAppDbContext.AcquireChannelLockAsync` (`pg_advisory_xact_lock` по int64 из GUID канала);
|
||||
`ScheduleGenerator.GenerateAsync` обёрнут транзакцией + lock, снял и **L4** (двойной save).
|
||||
- **M14** — `listAllMedia` (дозагрузка всех страниц, safety-cap 5000) в пикерах ShowDetail/ChannelDetail
|
||||
+ предупреждение при усечении (i18n ru/en). True server-pagination неуместна (клиентские
|
||||
исключение/парсинг/сортировка/bulk).
|
||||
- **L1** — 5 delete-методов портов → async с `CancellationToken`; рекурсивные удаления офлоадятся.
|
||||
- **L3** — `Show.AddEpisode` кидает при второй серии для Single (+тест). **L8** — OpenAPI/Scalar только
|
||||
вне прода/по флагу. **L11** — CLAUDE.md/README обновлены.
|
||||
|
||||
Разбор без правок кода:
|
||||
- **M10** — частично закрыт (M8 ограничивает худший случай, кэш делает рендеры редкими, M9 сериализует
|
||||
канал); полный вынос рендера из тика — отдельная архитектурная задача (флажок).
|
||||
- **M15** — сервер сам дедупит по имени файла; клиент это зеркалит и показывает `skipped`. Смена на
|
||||
имя+размер разошлась бы с сервером. Уникальность по имени — доменное решение.
|
||||
- **L9** — dev-дефолты, переопределяются env в проде; C2 форсит JWT-ключ.
|
||||
|
||||
Новые/обновлённые опции: `.env.example` (Storage__StreamTokenMinutes, Media__Probe/TranscodeTimeoutSeconds,
|
||||
Api:EnableOpenApi неявно). Тесты: +2 domain (override через полночь), +1 domain (Single).
|
||||
|
||||
Проверка: `dotnet build` 0/0 (TreatWarningsAsErrors); тесты **98 + 49** зелёные; frontend `tsc` чистый,
|
||||
`oxlint` без новых предупреждений.
|
||||
|
||||
---
|
||||
|
||||
## Осталось (флажки на будущее — требуют отдельного решения)
|
||||
|
||||
- **M10 (полностью)** — вынести рендер ТВ-заставок из тика планировщика: генерировать расписание с
|
||||
плейсхолдерами и рендерить ассеты асинхронно, чтобы один канал не задерживал достройку остальных и
|
||||
чтобы не держать транзакцию/advisory-lock во время ffmpeg.
|
||||
- **L12 (полностью)** — интеграционные тесты фоновых сервисов (media-конвейер, планировщик),
|
||||
оркестрации `ScheduleGenerator`, эндпоинтов (нужен реальный/контейнерный Postgres — InMemory не
|
||||
тянет транзакции/advisory-lock/raw SQL).
|
||||
- **M15 (доменно)** — если нужны разные файлы с одинаковым именем: пересмотреть уникальность
|
||||
`MediaAsset` (сейчас по `OriginalFileName`).
|
||||
|
||||
@@ -98,7 +98,7 @@ public static partial class ChannelEndpoints
|
||||
cancellationToken
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
storage.DeleteAudio(templateId);
|
||||
await storage.DeleteAudioAsync(templateId, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ public static partial class ChannelEndpoints
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RenderBumperPreviewQuery(id, templateId),
|
||||
new RenderBumperPreviewCommand(id, templateId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
||||
|
||||
@@ -84,7 +84,7 @@ public static class MediaEndpoints
|
||||
);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
storage.DeleteUpload(token);
|
||||
await storage.DeleteUploadAsync(token, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
|
||||
@@ -95,11 +95,18 @@ public static class StreamingEndpoints
|
||||
HttpRequest request,
|
||||
HttpResponse response,
|
||||
StreamTokenService tokens,
|
||||
IIdentityService identity,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!tokens.Validate(request.Cookies[StreamCookieName]))
|
||||
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
|
||||
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
|
||||
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
|
||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
|
||||
return Results.Unauthorized();
|
||||
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
||||
if (profile is null || profile.IsBlocked)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var result = await sender.Send(
|
||||
@@ -124,7 +131,7 @@ public static class StreamingEndpoints
|
||||
MediaPathResolver paths
|
||||
)
|
||||
{
|
||||
if (!tokens.Validate(request.Cookies[StreamCookieName]))
|
||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
|
||||
return Results.Unauthorized();
|
||||
if (!SegmentFileName.IsMatch(file))
|
||||
return Results.NotFound();
|
||||
|
||||
@@ -110,8 +110,13 @@ app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
|
||||
// не раскрывать полную карту эндпоинтов без необходимости.
|
||||
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Jwt": {
|
||||
"SigningKey": "telewave-development-only-signing-key-not-for-production-0001"
|
||||
},
|
||||
"AdminSeed": {
|
||||
"Username": "admin",
|
||||
"Password": "Passw0rd!Dev"
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ public sealed class ClearBumperTemplateAudioCommandHandler(
|
||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||
|
||||
template.ClearAudio();
|
||||
storage.DeleteAudio(command.TemplateId);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await storage.DeleteAudioAsync(command.TemplateId, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@ public sealed class RemoveBumperTemplateCommandHandler(
|
||||
return Result.Failure(ChannelErrors.CannotRemoveDefaultBumperTemplate);
|
||||
|
||||
channel.RemoveBumperTemplate(command.TemplateId);
|
||||
storage.DeleteTemplate(command.TemplateId);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await storage.DeleteTemplateAsync(command.TemplateId, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
/// <summary>
|
||||
/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок —
|
||||
/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет — read-side генерация артефактов.
|
||||
/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет, но пишет артефакты на диск —
|
||||
/// поэтому это команда (действие с побочным эффектом), а не запрос.
|
||||
/// </summary>
|
||||
public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId) : IQuery<Result>;
|
||||
public sealed record RenderBumperPreviewCommand(Guid ChannelId, Guid TemplateId) : ICommand<Result>;
|
||||
+3
-3
@@ -8,14 +8,14 @@ using TeleWave.Domain.Broadcast;
|
||||
|
||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||
|
||||
public sealed class RenderBumperPreviewQueryHandler(
|
||||
public sealed class RenderBumperPreviewCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IBumperRenderer renderer,
|
||||
IBumperTemplateStorage storage,
|
||||
IImageStore imageStore,
|
||||
IOptions<BumperOptions> bumperOptions,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
) : IQueryHandler<RenderBumperPreviewQuery, Result>
|
||||
) : ICommandHandler<RenderBumperPreviewCommand, Result>
|
||||
{
|
||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||
@@ -24,7 +24,7 @@ public sealed class RenderBumperPreviewQueryHandler(
|
||||
private const int DefaultBumperDurationSeconds = 8;
|
||||
|
||||
public async Task<Result> Handle(
|
||||
RenderBumperPreviewQuery query,
|
||||
RenderBumperPreviewCommand query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
|
||||
@@ -50,6 +50,12 @@ public sealed class ScheduleGenerator(
|
||||
if (channel is null || !channel.IsEnabled)
|
||||
return -1;
|
||||
|
||||
// Сериализуем генерацию одного канала: фоновый тик и ручная перегенерация не должны читать
|
||||
// одну точку продолжения и оба дописывать хвост (иначе дубли/перекрытия записей). Advisory-lock
|
||||
// держится до коммита/отката транзакции ниже; при сбое рендера заставок весь прогон откатится.
|
||||
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
|
||||
await dbContext.AcquireChannelLockAsync(channelId, cancellationToken);
|
||||
|
||||
var horizonEnd = now.AddDays(_options.HorizonDays);
|
||||
|
||||
// Чистим прошлое сверх окна ретеншна.
|
||||
@@ -77,6 +83,7 @@ public sealed class ScheduleGenerator(
|
||||
if (startTime >= horizonEnd)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -130,6 +137,7 @@ public sealed class ScheduleGenerator(
|
||||
channel.SetNextBumperIndex(result.NextBumperIndex);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return added;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using TeleWave.Domain.Auth;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Images;
|
||||
@@ -21,4 +22,13 @@ public interface IAppDbContext
|
||||
DbSet<Image> Images { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
|
||||
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
|
||||
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
|
||||
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
|
||||
/// Вызывать внутри открытой транзакции.</summary>
|
||||
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ public interface IBumperTemplateStorage
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
void DeleteAudio(Guid templateId);
|
||||
Task DeleteAudioAsync(Guid templateId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Удалить все файлы блока (при удалении самого блока).</summary>
|
||||
void DeleteTemplate(Guid templateId);
|
||||
Task DeleteTemplateAsync(Guid templateId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Абсолютный путь к загруженному звуку или null (нет расширения / файл отсутствует).</summary>
|
||||
string? AudioPath(Guid templateId, string? extension);
|
||||
|
||||
@@ -21,7 +21,7 @@ public interface IImageStore
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
void Delete(Guid imageId, string extension);
|
||||
Task DeleteAsync(Guid imageId, string extension, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Абсолютный путь к файлу изображения или null, если файла нет.</summary>
|
||||
string? ResolvePath(Guid imageId, string extension);
|
||||
|
||||
@@ -22,7 +22,7 @@ public interface IMediaStorage
|
||||
);
|
||||
|
||||
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
|
||||
void DeleteUpload(string uploadToken);
|
||||
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Переносит исходник из источника (<c>uploads/</c> для <see cref="MediaSource.Upload"/> либо
|
||||
@@ -37,5 +37,5 @@ public interface IMediaStorage
|
||||
);
|
||||
|
||||
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
|
||||
void DeleteAssetArtifacts(Guid assetId, string extension);
|
||||
Task DeleteAssetArtifactsAsync(Guid assetId, string extension, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,13 @@ public sealed class DeleteImageCommandHandler(IAppDbContext dbContext, IImageSto
|
||||
if (image is null)
|
||||
return Result.Failure(ImageErrors.NotFound);
|
||||
|
||||
storage.Delete(image.Id, image.FileExtension);
|
||||
// Сначала коммитим удаление в БД, затем файл (best-effort): orphan-файл безопаснее строки БД,
|
||||
// ссылающейся на уже удалённую картинку.
|
||||
var imageId = image.Id;
|
||||
var extension = image.FileExtension;
|
||||
dbContext.Images.Remove(image);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await storage.DeleteAsync(imageId, extension, cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
|
||||
+11
-3
@@ -17,12 +17,20 @@ public sealed class ClearAllMediaCommandHandler(IAppDbContext dbContext, IMediaS
|
||||
.MediaAssets.Select(a => new { a.Id, a.OriginalExtension })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var asset in assets)
|
||||
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
|
||||
|
||||
// Оба ExecuteDelete должны быть атомарны; файлы удаляем только после успешного коммита БД
|
||||
// (best-effort) — иначе при падении между ними расписание/ассеты рассинхронизируются с ФС.
|
||||
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
|
||||
// Расписание ссылается на удаляемые ассеты — чистим его тоже.
|
||||
await dbContext.ScheduleEntries.ExecuteDeleteAsync(cancellationToken);
|
||||
var deleted = await dbContext.MediaAssets.ExecuteDeleteAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
foreach (var asset in assets)
|
||||
await storage.DeleteAssetArtifactsAsync(
|
||||
asset.Id,
|
||||
asset.OriginalExtension,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success(deleted);
|
||||
}
|
||||
|
||||
+14
-4
@@ -28,9 +28,10 @@ public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMedi
|
||||
.Select(a => new { a.Id, a.OriginalExtension })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var asset in assets)
|
||||
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
|
||||
|
||||
// Все три операции БД (удаление записей расписания, ассетов, отвязка серий) — атомарно в одной
|
||||
// транзакции; файлы удаляем только после успешного коммита (best-effort), чтобы при падении не
|
||||
// остаться с сериями, указывающими на уже удалённые с диска ассеты.
|
||||
await using var transaction = await dbContext.BeginTransactionAsync(cancellationToken);
|
||||
await dbContext
|
||||
.ScheduleEntries.Where(e => assetIds.Contains(e.MediaAssetId))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
@@ -38,9 +39,18 @@ public sealed class DeleteShowMediaCommandHandler(IAppDbContext dbContext, IMedi
|
||||
.MediaAssets.Where(a => assetIds.Contains(a.Id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
// Серии шоу теперь указывают на удалённые ассеты — убираем их (сохранится через UnitOfWork).
|
||||
// Серии шоу теперь указывают на удалённые ассеты — убираем их.
|
||||
foreach (var episode in episodes)
|
||||
show.RemoveEpisode(episode.Id);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
foreach (var asset in assets)
|
||||
await storage.DeleteAssetArtifactsAsync(
|
||||
asset.Id,
|
||||
asset.OriginalExtension,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success(assets.Count);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,13 @@ public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMed
|
||||
return Result.Failure(MediaErrors.NotFound);
|
||||
|
||||
// TODO(этап 2): запретить удаление, пока ассет используется в расписании/пуле канала.
|
||||
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
|
||||
// Сначала коммитим удаление в БД, потом чистим файлы (best-effort): orphan-файл на диске
|
||||
// безопаснее, чем строка БД, ссылающаяся на уже удалённые артефакты.
|
||||
var extension = asset.OriginalExtension;
|
||||
var assetId = asset.Id;
|
||||
dbContext.MediaAssets.Remove(asset);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await storage.DeleteAssetArtifactsAsync(assetId, extension, cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
+21
-8
@@ -27,15 +27,28 @@ public sealed class RegisterMediaAssetCommandHandler(IAppDbContext dbContext, IM
|
||||
var extension = Path.GetExtension(command.OriginalFileName).ToLowerInvariant();
|
||||
var asset = MediaAsset.Register(command.OriginalFileName, extension, command.Source);
|
||||
|
||||
await storage.PromoteToOriginalAsync(
|
||||
command.Source,
|
||||
command.SourceToken,
|
||||
asset.Id,
|
||||
extension,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
// Сначала фиксируем ассет в БД, затем переносим файл в originals/. Если перенос не удался —
|
||||
// откатываем регистрацию, чтобы не оставить orphan (либо есть и строка БД, и файл, либо нет ни
|
||||
// того, ни другого). Обратный порядок оставлял бы перемещённый файл без строки БД при сбое save.
|
||||
dbContext.MediaAssets.Add(asset);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await storage.PromoteToOriginalAsync(
|
||||
command.Source,
|
||||
command.SourceToken,
|
||||
asset.Id,
|
||||
extension,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
dbContext.MediaAssets.Remove(asset);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
return Result.Success(asset.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,9 @@ public sealed class StreamingOptions
|
||||
|
||||
/// <summary>Сколько сегментов держать в скользящем окне live-плейлиста.</summary>
|
||||
public int LiveWindowSegments { get; init; } = 10;
|
||||
|
||||
/// <summary>TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа
|
||||
/// после блокировки/логаута; фронт перевыпускает cookie через /watch, пока идёт просмотр. Блокировка
|
||||
/// пользователя при этом отражается почти сразу — при очередной перезагрузке live-плейлиста.</summary>
|
||||
public int StreamTokenMinutes { get; init; } = 30;
|
||||
}
|
||||
|
||||
@@ -69,9 +69,16 @@ public class Show
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия.</summary>
|
||||
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
|
||||
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
|
||||
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
|
||||
public ShowEpisode AddEpisode(Guid mediaAssetId)
|
||||
{
|
||||
if (!CanAddEpisode)
|
||||
throw new InvalidOperationException(
|
||||
"Полнометражка (ShowKind.Single) может содержать только одну серию."
|
||||
);
|
||||
|
||||
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
|
||||
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
|
||||
_episodes.Add(episode);
|
||||
|
||||
@@ -17,15 +17,23 @@ public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemp
|
||||
CancellationToken cancellationToken
|
||||
) => SaveAsync(templateId, Audio, extension, content, cancellationToken);
|
||||
|
||||
public void DeleteAudio(Guid templateId) => DeleteKind(templateId, Audio);
|
||||
|
||||
public void DeleteTemplate(Guid templateId)
|
||||
public Task DeleteAudioAsync(Guid templateId, CancellationToken cancellationToken)
|
||||
{
|
||||
var dir = paths.BumperTemplateDir(templateId);
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
DeleteKind(templateId, Audio);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteTemplateAsync(Guid templateId, CancellationToken cancellationToken) =>
|
||||
Task.Run(
|
||||
() =>
|
||||
{
|
||||
var dir = paths.BumperTemplateDir(templateId);
|
||||
if (Directory.Exists(dir))
|
||||
Directory.Delete(dir, recursive: true);
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
public string? AudioPath(Guid templateId, string? extension) =>
|
||||
ResolvePath(templateId, Audio, extension);
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ public sealed class FfmpegBumperRenderer(
|
||||
_media.FfmpegPath,
|
||||
args,
|
||||
lowPriority: true,
|
||||
TimeSpan.FromSeconds(_media.TranscodeTimeoutSeconds),
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
|
||||
@@ -49,6 +49,7 @@ public sealed class FfmpegMediaProcessor(
|
||||
_media.FfmpegPath,
|
||||
args,
|
||||
lowPriority: true,
|
||||
TimeSpan.FromSeconds(_media.TranscodeTimeoutSeconds),
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
@@ -169,6 +170,7 @@ public sealed class FfmpegMediaProcessor(
|
||||
_media.FfprobePath,
|
||||
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path],
|
||||
lowPriority: false,
|
||||
TimeSpan.FromSeconds(_media.ProbeTimeoutSeconds),
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class FfprobeAudioProbe(IOptions<MediaOptions> mediaOptions) : IAu
|
||||
_media.FfprobePath,
|
||||
["-v", "quiet", "-print_format", "json", "-show_format", absolutePath],
|
||||
lowPriority: false,
|
||||
TimeSpan.FromSeconds(_media.ProbeTimeoutSeconds),
|
||||
cancellationToken
|
||||
);
|
||||
if (result.ExitCode != 0)
|
||||
|
||||
@@ -43,11 +43,12 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
||||
return token;
|
||||
}
|
||||
|
||||
public void DeleteUpload(string uploadToken)
|
||||
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = paths.UploadPath(uploadToken);
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PromoteToOriginalAsync(
|
||||
@@ -72,14 +73,24 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void DeleteAssetArtifacts(Guid assetId, string extension)
|
||||
{
|
||||
var original = paths.OriginalPath(assetId, extension);
|
||||
if (File.Exists(original))
|
||||
File.Delete(original);
|
||||
public Task DeleteAssetArtifactsAsync(
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
|
||||
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
|
||||
Task.Run(
|
||||
() =>
|
||||
{
|
||||
var original = paths.OriginalPath(assetId, extension);
|
||||
if (File.Exists(original))
|
||||
File.Delete(original);
|
||||
|
||||
var assetDir = paths.AssetDir(assetId);
|
||||
if (Directory.Exists(assetDir))
|
||||
Directory.Delete(assetDir, recursive: true);
|
||||
}
|
||||
var assetDir = paths.AssetDir(assetId);
|
||||
if (Directory.Exists(assetDir))
|
||||
Directory.Delete(assetDir, recursive: true);
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,11 +30,12 @@ public sealed class ImageStore(MediaPathResolver paths) : IImageStore
|
||||
await File.WriteAllBytesAsync(path, content, cancellationToken);
|
||||
}
|
||||
|
||||
public void Delete(Guid imageId, string extension)
|
||||
public Task DeleteAsync(Guid imageId, string extension, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = paths.ImagePath(imageId, Normalize(extension));
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public string? ResolvePath(Guid imageId, string extension)
|
||||
|
||||
@@ -28,4 +28,12 @@ public sealed class MediaOptions
|
||||
|
||||
/// <summary>Целевая интегральная громкость, LUFS (−16 — типично для стриминга; тише — уменьшить).</summary>
|
||||
public int LoudnessTargetLufs { get; init; } = -16;
|
||||
|
||||
/// <summary>Таймаут одного вызова ffprobe, секунды (0 — без таймаута). Пробирование метаданных —
|
||||
/// быстрая операция; страхует от подвисшего ffprobe на битом/сетевом источнике.</summary>
|
||||
public int ProbeTimeoutSeconds { get; init; } = 120;
|
||||
|
||||
/// <summary>Таймаут одного вызова ffmpeg (транскод/рендер заставки), секунды (0 — без таймаута).
|
||||
/// Не даёт зависшему процессу вечно держать слот параллелизма. Поднять для очень длинных файлов.</summary>
|
||||
public int TranscodeTimeoutSeconds { get; init; } = 1800;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ internal static class ProcessRunner
|
||||
string fileName,
|
||||
IEnumerable<string> arguments,
|
||||
bool lowPriority,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
@@ -58,13 +59,30 @@ internal static class ProcessRunner
|
||||
}
|
||||
}
|
||||
|
||||
// Таймаут (если задан) — отдельный источник отмены, слитый с внешним: зависший ffmpeg/ffprobe
|
||||
// (битый источник, -stream_loop и т.п.) не должен держать слот параллелизма/тик планировщика вечно.
|
||||
using var timeoutCts =
|
||||
timeout > TimeSpan.Zero ? new CancellationTokenSource(timeout) : null;
|
||||
using var linked =
|
||||
timeoutCts is null
|
||||
? null
|
||||
: CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken,
|
||||
timeoutCts.Token
|
||||
);
|
||||
var waitToken = linked?.Token ?? cancellationToken;
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(waitToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryKill(process);
|
||||
if (timeoutCts is { IsCancellationRequested: true } && !cancellationToken.IsCancellationRequested)
|
||||
throw new TimeoutException(
|
||||
$"Процесс {fileName} превысил таймаут {timeout.TotalSeconds:0}с и был прерван."
|
||||
);
|
||||
throw;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Domain.Auth;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
@@ -30,6 +31,21 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||
public DbSet<Image> Images => Set<Image>();
|
||||
|
||||
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
|
||||
Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken)
|
||||
{
|
||||
// pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции.
|
||||
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
|
||||
// корректности не нарушают.
|
||||
var key = BitConverter.ToInt64(channelId.ToByteArray());
|
||||
return Database.ExecuteSqlAsync(
|
||||
$"SELECT pg_advisory_xact_lock({key})",
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Infrastructure.Identity;
|
||||
|
||||
namespace TeleWave.Infrastructure.Streaming;
|
||||
@@ -8,29 +9,37 @@ namespace TeleWave.Infrastructure.Streaming;
|
||||
/// <summary>
|
||||
/// Короткоживущий подписанный токен для cookie <c>tw_stream</c>: выдаётся авторизованному зрителю,
|
||||
/// проверяется на запросах плейлиста и сегментов (работает и с нативным HLS, где заголовок не
|
||||
/// поставить). Подпись — HMAC-SHA256 на том же ключе, что и JWT.
|
||||
/// поставить). Подпись — HMAC-SHA256 на том же ключе, что и JWT. TTL короткий (см.
|
||||
/// <see cref="StreamingOptions.StreamTokenMinutes"/>), а <see cref="Validate"/> возвращает id зрителя —
|
||||
/// эндпоинт плейлиста дополнительно сверяет, что пользователь не заблокирован.
|
||||
/// </summary>
|
||||
public sealed class StreamTokenService(IOptions<JwtOptions> jwtOptions)
|
||||
public sealed class StreamTokenService(
|
||||
IOptions<JwtOptions> jwtOptions,
|
||||
IOptions<StreamingOptions> streamingOptions
|
||||
)
|
||||
{
|
||||
private static readonly TimeSpan Ttl = TimeSpan.FromHours(6);
|
||||
private readonly TimeSpan _ttl = TimeSpan.FromMinutes(
|
||||
Math.Max(1, streamingOptions.Value.StreamTokenMinutes)
|
||||
);
|
||||
private readonly byte[] _key = Encoding.UTF8.GetBytes(jwtOptions.Value.SigningKey);
|
||||
|
||||
public (string Token, DateTimeOffset ExpiresAt) Issue(Guid userId)
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(Ttl);
|
||||
var expiresAt = DateTimeOffset.UtcNow.Add(_ttl);
|
||||
var payload = $"{userId:N}.{expiresAt.ToUnixTimeSeconds()}";
|
||||
var token = $"{Base64Url(Encoding.UTF8.GetBytes(payload))}.{Base64Url(Sign(payload))}";
|
||||
return (token, expiresAt);
|
||||
}
|
||||
|
||||
public bool Validate(string? token)
|
||||
/// <summary>Проверяет подпись и срок; при успехе возвращает id зрителя из токена, иначе null.</summary>
|
||||
public Guid? Validate(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 2)
|
||||
return false;
|
||||
return null;
|
||||
|
||||
byte[] payloadBytes;
|
||||
byte[] signature;
|
||||
@@ -41,19 +50,22 @@ public sealed class StreamTokenService(IOptions<JwtOptions> jwtOptions)
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = Encoding.UTF8.GetString(payloadBytes);
|
||||
var expected = Sign(payload);
|
||||
if (!CryptographicOperations.FixedTimeEquals(signature, expected))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
var dot = payload.LastIndexOf('.');
|
||||
if (dot < 0 || !long.TryParse(payload.AsSpan(dot + 1), out var expiresUnix))
|
||||
return false;
|
||||
return null;
|
||||
|
||||
return DateTimeOffset.FromUnixTimeSeconds(expiresUnix) > DateTimeOffset.UtcNow;
|
||||
if (DateTimeOffset.FromUnixTimeSeconds(expiresUnix) <= DateTimeOffset.UtcNow)
|
||||
return null;
|
||||
|
||||
return Guid.TryParseExact(payload[..dot], "N", out var userId) ? userId : null;
|
||||
}
|
||||
|
||||
private byte[] Sign(string payload) =>
|
||||
|
||||
@@ -268,7 +268,7 @@ public class QueryHandlersTests
|
||||
);
|
||||
Assert.True(removed.IsSuccess);
|
||||
}
|
||||
storage.Received(1).DeleteTemplate(templateId);
|
||||
await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any<CancellationToken>());
|
||||
|
||||
// дефолтный блок удалить нельзя
|
||||
await using (var db = fixture.New())
|
||||
|
||||
@@ -81,6 +81,16 @@ public class ShowTests
|
||||
Assert.True(series.CanAddEpisode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEpisode_SecondForSingle_Throws()
|
||||
{
|
||||
var single = Show.Create("Movie", ShowKind.Single);
|
||||
single.AddEpisode(Guid.NewGuid());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => single.AddEpisode(Guid.NewGuid()));
|
||||
Assert.Single(single.Episodes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyMetadata_SetsProviderYearPoster_KeepsDescriptionIfBlank()
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { listMedia } from '@/features/admin/media/api'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { deleteOverride, getChannel, getSchedule, regenerateSchedule, removeChannelAd } from './api'
|
||||
import { AddAdForm } from './components/AddAdForm'
|
||||
@@ -30,8 +30,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
})
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||
const { data: ready } = useQuery({
|
||||
queryKey: ['admin', 'media', 'ready'],
|
||||
queryFn: () => listMedia({ page: 1, pageSize: 100, statuses: ['Ready'] }),
|
||||
queryKey: ['admin', 'media', 'ready', 'all'],
|
||||
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
|
||||
})
|
||||
const { data: schedule } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'schedule'],
|
||||
@@ -141,6 +141,9 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
onAdded={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
{ready?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
|
||||
)}
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{channel.ads.map((ad) => (
|
||||
<li key={ad.id} className="flex items-center justify-between py-2 text-sm">
|
||||
|
||||
@@ -23,6 +23,33 @@ export function listMedia(params: ListMediaParams) {
|
||||
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
||||
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
||||
* если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид,
|
||||
* что список полон).
|
||||
*/
|
||||
export async function listAllMedia(
|
||||
params: Omit<ListMediaParams, 'page' | 'pageSize'> & { cap?: number },
|
||||
): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> {
|
||||
const pageSize = 200
|
||||
const cap = params.cap ?? 5000
|
||||
const items: MediaAssetDto[] = []
|
||||
let total = 0
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await listMedia({
|
||||
page,
|
||||
pageSize,
|
||||
statuses: params.statuses,
|
||||
search: params.search,
|
||||
})
|
||||
total = res.total
|
||||
items.push(...res.items)
|
||||
if (res.items.length === 0 || items.length >= total || items.length >= cap) break
|
||||
}
|
||||
return { items, total, truncated: items.length < total }
|
||||
}
|
||||
|
||||
export function deleteMedia(id: string) {
|
||||
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Pager } from '@/shared/ui/pager'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { listMedia } from '@/features/admin/media/api'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
import {
|
||||
type ParsedEpisode,
|
||||
compareParsed,
|
||||
@@ -40,8 +40,8 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
queryFn: () => getShow(showId),
|
||||
})
|
||||
const { data: ready } = useQuery({
|
||||
queryKey: ['admin', 'media', 'ready'],
|
||||
queryFn: () => listMedia({ page: 1, pageSize: 500, statuses: ['Ready'] }),
|
||||
queryKey: ['admin', 'media', 'ready', 'all'],
|
||||
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] })
|
||||
@@ -212,6 +212,9 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{ready?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
|
||||
)}
|
||||
<Pager page={candPageSafe} totalPages={candTotalPages} onChange={setCandPage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -57,6 +57,19 @@ export function AirPage() {
|
||||
}
|
||||
}, [selected, attempt])
|
||||
|
||||
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
|
||||
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
|
||||
useEffect(() => {
|
||||
if (!selected || playerError) return
|
||||
const id = window.setInterval(
|
||||
() => {
|
||||
void watchChannel(selected).catch(() => undefined)
|
||||
},
|
||||
20 * 60_000,
|
||||
)
|
||||
return () => window.clearInterval(id)
|
||||
}, [selected, playerError])
|
||||
|
||||
const { data: epg } = useQuery({
|
||||
queryKey: ['air', 'epg', selected],
|
||||
queryFn: () =>
|
||||
|
||||
@@ -194,6 +194,8 @@ const resources = {
|
||||
addSelected: 'Добавить выбранные',
|
||||
noMatches: 'Нет подходящих файлов',
|
||||
addedCount: 'Добавлено серий: {{count}}',
|
||||
candidatesTruncated:
|
||||
'Показаны не все файлы (слишком много готовых ассетов) — уточните фильтр, чтобы увидеть остальные.',
|
||||
},
|
||||
channels: {
|
||||
title: 'Каналы',
|
||||
@@ -554,6 +556,8 @@ const resources = {
|
||||
addSelected: 'Add selected',
|
||||
noMatches: 'No matching files',
|
||||
addedCount: 'Episodes added: {{count}}',
|
||||
candidatesTruncated:
|
||||
'Not all files are shown (too many ready assets) — narrow the filter to see the rest.',
|
||||
},
|
||||
channels: {
|
||||
title: 'Channels',
|
||||
|
||||
Reference in New Issue
Block a user