Compare commits

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