Compare commits
2
Commits
058cbc6994
...
6c18a9da79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c18a9da79 | ||
|
|
a261e261f0 |
@@ -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();
|
||||
}
|
||||
|
||||
+14
-1
@@ -27,6 +27,13 @@ public sealed class RegisterMediaAssetCommandHandler(IAppDbContext dbContext, IM
|
||||
var extension = Path.GetExtension(command.OriginalFileName).ToLowerInvariant();
|
||||
var asset = MediaAsset.Register(command.OriginalFileName, extension, command.Source);
|
||||
|
||||
// Сначала фиксируем ассет в БД, затем переносим файл в originals/. Если перенос не удался —
|
||||
// откатываем регистрацию, чтобы не оставить orphan (либо есть и строка БД, и файл, либо нет ни
|
||||
// того, ни другого). Обратный порядок оставлял бы перемещённый файл без строки БД при сбое save.
|
||||
dbContext.MediaAssets.Add(asset);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await storage.PromoteToOriginalAsync(
|
||||
command.Source,
|
||||
command.SourceToken,
|
||||
@@ -34,8 +41,14 @@ public sealed class RegisterMediaAssetCommandHandler(IAppDbContext dbContext, IM
|
||||
extension,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
dbContext.MediaAssets.Remove(asset);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
dbContext.MediaAssets.Add(asset);
|
||||
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,14 +17,22 @@ public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemp
|
||||
CancellationToken cancellationToken
|
||||
) => SaveAsync(templateId, Audio, extension, content, cancellationToken);
|
||||
|
||||
public void DeleteAudio(Guid templateId) => DeleteKind(templateId, Audio);
|
||||
public Task DeleteAudioAsync(Guid templateId, CancellationToken cancellationToken)
|
||||
{
|
||||
DeleteKind(templateId, Audio);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void DeleteTemplate(Guid templateId)
|
||||
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,7 +73,15 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void DeleteAssetArtifacts(Guid assetId, string extension)
|
||||
public Task DeleteAssetArtifactsAsync(
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
|
||||
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
|
||||
Task.Run(
|
||||
() =>
|
||||
{
|
||||
var original = paths.OriginalPath(assetId, extension);
|
||||
if (File.Exists(original))
|
||||
@@ -81,5 +90,7 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor
|
||||
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