diff --git a/.env.example b/.env.example index 860ada6..945be93 100644 --- a/.env.example +++ b/.env.example @@ -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) ─────────────────────────────────── # Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность, diff --git a/CLAUDE.md b/CLAUDE.md index 1be15d0..e6dec8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - Не коммить и не пуши без явной просьбы. - Отвечай пользователю на русском. diff --git a/README.md b/README.md index db3ef84..c748342 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ **TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из хранилища на сервере, админ управляет каналами и пользователями. -> Текущее состояние — **база**: вход/регистрация, роли, пользователи, минимальная админка, -> приветственная главная страница. Каталог каналов и раздача видео — следующий этап, пока не -> реализованы. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL — +> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка; +> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS), метаданные (TMDb/OMDb), каталог +> каналов, планировщик эфира (реклама, ТВ-заставки, weekly-override'ы) и live-раздача HLS с публичным +> просмотром сетки. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL — > **внешний**, не поднимается через compose. ## Стек diff --git a/REVIEW.md b/REVIEW.md index 0255e01..8f3159a 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -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`). diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs index 275040e..c22ffe8 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.Bumpers.cs @@ -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(); diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index d8a79d0..116ad1f 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -84,7 +84,7 @@ public static class MediaEndpoints ); if (!result.IsSuccess) { - storage.DeleteUpload(token); + await storage.DeleteUploadAsync(token, cancellationToken); return result.ToHttpResult(); } diff --git a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs index c0be05c..aff5998 100644 --- a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs @@ -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(); diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index 0c94fa5..7f78dac 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -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"); diff --git a/backend/src/TeleWave.Api/appsettings.Development.json b/backend/src/TeleWave.Api/appsettings.Development.json index bcc2915..09b30c4 100644 --- a/backend/src/TeleWave.Api/appsettings.Development.json +++ b/backend/src/TeleWave.Api/appsettings.Development.json @@ -5,6 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, + "Jwt": { + "SigningKey": "telewave-development-only-signing-key-not-for-production-0001" + }, "AdminSeed": { "Username": "admin", "Password": "Passw0rd!Dev" diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs index a117402..a9afa17 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/ClearBumperTemplateAudioCommandHandler.cs @@ -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(); } } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs index 02a5a99..48eae86 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RemoveBumperTemplateCommandHandler.cs @@ -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(); } } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQuery.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommand.cs similarity index 53% rename from backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQuery.cs rename to backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommand.cs index b132b40..5c27dc1 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewQuery.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommand.cs @@ -5,6 +5,7 @@ namespace TeleWave.Application.Broadcast.Bumpers; /// /// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок — -/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет — read-side генерация артефактов. +/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет, но пишет артефакты на диск — +/// поэтому это команда (действие с побочным эффектом), а не запрос. /// -public sealed record RenderBumperPreviewQuery(Guid ChannelId, Guid TemplateId) : IQuery; +public sealed record RenderBumperPreviewCommand(Guid ChannelId, Guid TemplateId) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs index 0568f9e..3cfb486 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/RenderBumperPreviewCommandHandler.cs @@ -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, IOptions streamingOptions -) : IQueryHandler +) : ICommandHandler { 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 Handle( - RenderBumperPreviewQuery query, + RenderBumperPreviewCommand query, CancellationToken cancellationToken ) { diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index 5d7fe75..4ad2a41 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -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; } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs index 8b5e331..e6b5a3f 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs @@ -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 Images { get; } Task SaveChangesAsync(CancellationToken cancellationToken); + + /// Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч. + /// ExecuteDelete в обход change-tracker), которые должны быть атомарны. + Task BeginTransactionAsync(CancellationToken cancellationToken); + + /// Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате). + /// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией. + /// Вызывать внутри открытой транзакции. + Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken); } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs index ed86c87..f3e4234 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IBumperTemplateStorage.cs @@ -13,10 +13,10 @@ public interface IBumperTemplateStorage CancellationToken cancellationToken ); - void DeleteAudio(Guid templateId); + Task DeleteAudioAsync(Guid templateId, CancellationToken cancellationToken); /// Удалить все файлы блока (при удалении самого блока). - void DeleteTemplate(Guid templateId); + Task DeleteTemplateAsync(Guid templateId, CancellationToken cancellationToken); /// Абсолютный путь к загруженному звуку или null (нет расширения / файл отсутствует). string? AudioPath(Guid templateId, string? extension); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IImageStore.cs b/backend/src/TeleWave.Application/Common/Interfaces/IImageStore.cs index c4080a3..fe3367b 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IImageStore.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IImageStore.cs @@ -21,7 +21,7 @@ public interface IImageStore CancellationToken cancellationToken ); - void Delete(Guid imageId, string extension); + Task DeleteAsync(Guid imageId, string extension, CancellationToken cancellationToken); /// Абсолютный путь к файлу изображения или null, если файла нет. string? ResolvePath(Guid imageId, string extension); diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs index b7e059b..3714726 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IMediaStorage.cs @@ -22,7 +22,7 @@ public interface IMediaStorage ); /// Удаляет временный файл загрузки (откат при ошибке до регистрации ассета). - void DeleteUpload(string uploadToken); + Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken); /// /// Переносит исходник из источника (uploads/ для либо @@ -37,5 +37,5 @@ public interface IMediaStorage ); /// Удаляет все артефакты ассета: исходник в originals/ и каталог сегментов assets/{id}/. - void DeleteAssetArtifacts(Guid assetId, string extension); + Task DeleteAssetArtifactsAsync(Guid assetId, string extension, CancellationToken cancellationToken); } diff --git a/backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommandHandler.cs b/backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommandHandler.cs index 286da08..b6ffad2 100644 --- a/backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommandHandler.cs +++ b/backend/src/TeleWave.Application/Images/DeleteImage/DeleteImageCommandHandler.cs @@ -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(); } } diff --git a/backend/src/TeleWave.Application/Maintenance/ClearAllMedia/ClearAllMediaCommandHandler.cs b/backend/src/TeleWave.Application/Maintenance/ClearAllMedia/ClearAllMediaCommandHandler.cs index 4f3c8d9..78f5bc1 100644 --- a/backend/src/TeleWave.Application/Maintenance/ClearAllMedia/ClearAllMediaCommandHandler.cs +++ b/backend/src/TeleWave.Application/Maintenance/ClearAllMedia/ClearAllMediaCommandHandler.cs @@ -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); } diff --git a/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs b/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs index 910a3f2..daf65ba 100644 --- a/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs +++ b/backend/src/TeleWave.Application/Maintenance/DeleteShowMedia/DeleteShowMediaCommandHandler.cs @@ -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); } diff --git a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs index b67b749..d03bab2 100644 --- a/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/Delete/DeleteMediaAssetCommandHandler.cs @@ -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(); } diff --git a/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs b/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs index 40aa915..cfb2a11 100644 --- a/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs +++ b/backend/src/TeleWave.Application/Media/Register/RegisterMediaAssetCommandHandler.cs @@ -27,15 +27,28 @@ public sealed class RegisterMediaAssetCommandHandler(IAppDbContext dbContext, IM var extension = Path.GetExtension(command.OriginalFileName).ToLowerInvariant(); var asset = MediaAsset.Register(command.OriginalFileName, extension, command.Source); - await storage.PromoteToOriginalAsync( - command.Source, - command.SourceToken, - asset.Id, - extension, - cancellationToken - ); - + // Сначала фиксируем ассет в БД, затем переносим файл в originals/. Если перенос не удался — + // откатываем регистрацию, чтобы не оставить orphan (либо есть и строка БД, и файл, либо нет ни + // того, ни другого). Обратный порядок оставлял бы перемещённый файл без строки БД при сбое save. dbContext.MediaAssets.Add(asset); + await dbContext.SaveChangesAsync(cancellationToken); + try + { + await storage.PromoteToOriginalAsync( + command.Source, + command.SourceToken, + asset.Id, + extension, + cancellationToken + ); + } + catch + { + dbContext.MediaAssets.Remove(asset); + await dbContext.SaveChangesAsync(cancellationToken); + throw; + } + return Result.Success(asset.Id); } } diff --git a/backend/src/TeleWave.Application/Streaming/StreamingOptions.cs b/backend/src/TeleWave.Application/Streaming/StreamingOptions.cs index 06ebe0f..1e3d979 100644 --- a/backend/src/TeleWave.Application/Streaming/StreamingOptions.cs +++ b/backend/src/TeleWave.Application/Streaming/StreamingOptions.cs @@ -9,4 +9,9 @@ public sealed class StreamingOptions /// Сколько сегментов держать в скользящем окне live-плейлиста. public int LiveWindowSegments { get; init; } = 10; + + /// TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа + /// после блокировки/логаута; фронт перевыпускает cookie через /watch, пока идёт просмотр. Блокировка + /// пользователя при этом отражается почти сразу — при очередной перезагрузке live-плейлиста. + public int StreamTokenMinutes { get; init; } = 30; } diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs index bf72148..4505e7b 100644 --- a/backend/src/TeleWave.Domain/Library/Show.cs +++ b/backend/src/TeleWave.Domain/Library/Show.cs @@ -69,9 +69,16 @@ public class Show private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - /// Добавляет серию в конец. Для допустима ровно одна серия. + /// Добавляет серию в конец. Для допустима ровно одна серия + /// (инвариант защищён самим агрегатом; вызывающий обычно проверяет заранее + /// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода). 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); diff --git a/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs b/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs index 5852aaf..8639a9e 100644 --- a/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/BumperTemplateStorage.cs @@ -17,15 +17,23 @@ public sealed class BumperTemplateStorage(MediaPathResolver paths) : IBumperTemp CancellationToken cancellationToken ) => SaveAsync(templateId, Audio, extension, content, cancellationToken); - public void DeleteAudio(Guid templateId) => DeleteKind(templateId, Audio); - - public void DeleteTemplate(Guid templateId) + public Task DeleteAudioAsync(Guid templateId, CancellationToken cancellationToken) { - var dir = paths.BumperTemplateDir(templateId); - if (Directory.Exists(dir)) - Directory.Delete(dir, recursive: true); + DeleteKind(templateId, Audio); + return Task.CompletedTask; } + public Task DeleteTemplateAsync(Guid templateId, CancellationToken cancellationToken) => + Task.Run( + () => + { + var dir = paths.BumperTemplateDir(templateId); + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + }, + cancellationToken + ); + public string? AudioPath(Guid templateId, string? extension) => ResolvePath(templateId, Audio, extension); diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs index e83f90a..8faa877 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegBumperRenderer.cs @@ -82,6 +82,7 @@ public sealed class FfmpegBumperRenderer( _media.FfmpegPath, args, lowPriority: true, + TimeSpan.FromSeconds(_media.TranscodeTimeoutSeconds), cancellationToken ); if (result.ExitCode != 0) diff --git a/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs b/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs index 87ae184..29101b0 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfmpegMediaProcessor.cs @@ -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) diff --git a/backend/src/TeleWave.Infrastructure/Media/FfprobeAudioProbe.cs b/backend/src/TeleWave.Infrastructure/Media/FfprobeAudioProbe.cs index 07dd402..45a9cc4 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FfprobeAudioProbe.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FfprobeAudioProbe.cs @@ -27,6 +27,7 @@ public sealed class FfprobeAudioProbe(IOptions mediaOptions) : IAu _media.FfprobePath, ["-v", "quiet", "-print_format", "json", "-show_format", absolutePath], lowPriority: false, + TimeSpan.FromSeconds(_media.ProbeTimeoutSeconds), cancellationToken ); if (result.ExitCode != 0) diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs index bda1375..2918201 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemMediaStorage.cs @@ -43,11 +43,12 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor return token; } - public void DeleteUpload(string uploadToken) + public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken) { var path = paths.UploadPath(uploadToken); if (File.Exists(path)) File.Delete(path); + return Task.CompletedTask; } public Task PromoteToOriginalAsync( @@ -72,14 +73,24 @@ public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStor return Task.CompletedTask; } - public void DeleteAssetArtifacts(Guid assetId, string extension) - { - var original = paths.OriginalPath(assetId, extension); - if (File.Exists(original)) - File.Delete(original); + public Task DeleteAssetArtifactsAsync( + Guid assetId, + string extension, + CancellationToken cancellationToken + ) => + // Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока + // (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O. + Task.Run( + () => + { + var original = paths.OriginalPath(assetId, extension); + if (File.Exists(original)) + File.Delete(original); - var assetDir = paths.AssetDir(assetId); - if (Directory.Exists(assetDir)) - Directory.Delete(assetDir, recursive: true); - } + var assetDir = paths.AssetDir(assetId); + if (Directory.Exists(assetDir)) + Directory.Delete(assetDir, recursive: true); + }, + cancellationToken + ); } diff --git a/backend/src/TeleWave.Infrastructure/Media/ImageStore.cs b/backend/src/TeleWave.Infrastructure/Media/ImageStore.cs index 7e5ebfb..6e5f318 100644 --- a/backend/src/TeleWave.Infrastructure/Media/ImageStore.cs +++ b/backend/src/TeleWave.Infrastructure/Media/ImageStore.cs @@ -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) diff --git a/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs b/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs index 503dd98..073fbdd 100644 --- a/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs +++ b/backend/src/TeleWave.Infrastructure/Media/MediaOptions.cs @@ -28,4 +28,12 @@ public sealed class MediaOptions /// Целевая интегральная громкость, LUFS (−16 — типично для стриминга; тише — уменьшить). public int LoudnessTargetLufs { get; init; } = -16; + + /// Таймаут одного вызова ffprobe, секунды (0 — без таймаута). Пробирование метаданных — + /// быстрая операция; страхует от подвисшего ffprobe на битом/сетевом источнике. + public int ProbeTimeoutSeconds { get; init; } = 120; + + /// Таймаут одного вызова ffmpeg (транскод/рендер заставки), секунды (0 — без таймаута). + /// Не даёт зависшему процессу вечно держать слот параллелизма. Поднять для очень длинных файлов. + public int TranscodeTimeoutSeconds { get; init; } = 1800; } diff --git a/backend/src/TeleWave.Infrastructure/Media/ProcessRunner.cs b/backend/src/TeleWave.Infrastructure/Media/ProcessRunner.cs index d21c6e6..c8611cb 100644 --- a/backend/src/TeleWave.Infrastructure/Media/ProcessRunner.cs +++ b/backend/src/TeleWave.Infrastructure/Media/ProcessRunner.cs @@ -13,6 +13,7 @@ internal static class ProcessRunner string fileName, IEnumerable 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; } diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index fe0ab82..dc6077e 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -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 options) public DbSet AppSettings => Set(); public DbSet Images => Set(); + public Task 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); diff --git a/backend/src/TeleWave.Infrastructure/Streaming/StreamTokenService.cs b/backend/src/TeleWave.Infrastructure/Streaming/StreamTokenService.cs index 991e854..80af543 100644 --- a/backend/src/TeleWave.Infrastructure/Streaming/StreamTokenService.cs +++ b/backend/src/TeleWave.Infrastructure/Streaming/StreamTokenService.cs @@ -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; /// /// Короткоживущий подписанный токен для cookie tw_stream: выдаётся авторизованному зрителю, /// проверяется на запросах плейлиста и сегментов (работает и с нативным HLS, где заголовок не -/// поставить). Подпись — HMAC-SHA256 на том же ключе, что и JWT. +/// поставить). Подпись — HMAC-SHA256 на том же ключе, что и JWT. TTL короткий (см. +/// ), а возвращает id зрителя — +/// эндпоинт плейлиста дополнительно сверяет, что пользователь не заблокирован. /// -public sealed class StreamTokenService(IOptions jwtOptions) +public sealed class StreamTokenService( + IOptions jwtOptions, + IOptions 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) + /// Проверяет подпись и срок; при успехе возвращает id зрителя из токена, иначе null. + 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) } 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) => diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs index e22d4a7..e171702 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/QueryHandlersTests.cs @@ -268,7 +268,7 @@ public class QueryHandlersTests ); Assert.True(removed.IsSuccess); } - storage.Received(1).DeleteTemplate(templateId); + await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any()); // дефолтный блок удалить нельзя await using (var db = fixture.New()) diff --git a/backend/tests/TeleWave.Domain.Tests/Library/ShowTests.cs b/backend/tests/TeleWave.Domain.Tests/Library/ShowTests.cs index 556d97e..879d40c 100644 --- a/backend/tests/TeleWave.Domain.Tests/Library/ShowTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Library/ShowTests.cs @@ -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(() => single.AddEpisode(Guid.NewGuid())); + Assert.Single(single.Episodes); + } + [Fact] public void ApplyMetadata_SetsProviderYearPoster_KeepsDescriptionIfBlank() { diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index 9d1ab13..e417a5c 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -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 && ( +

{t('admin.shows.candidatesTruncated')}

+ )}
    {channel.ads.map((ad) => (
  • diff --git a/frontend/src/features/admin/media/api.ts b/frontend/src/features/admin/media/api.ts index d88200c..ed80ce3 100644 --- a/frontend/src/features/admin/media/api.ts +++ b/frontend/src/features/admin/media/api.ts @@ -23,6 +23,33 @@ export function listMedia(params: ListMediaParams) { return apiRequest>(`/admin/media?${query.toString()}`) } +/** + * Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий, + * пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`: + * если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид, + * что список полон). + */ +export async function listAllMedia( + params: Omit & { 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(`/admin/media/${id}`, { method: 'DELETE' }) } diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index 5da0a62..a02a3db 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -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 }) {
)} + {ready?.truncated && ( +

{t('admin.shows.candidatesTruncated')}

+ )} )} diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx index 11d544e..3c4ba45 100644 --- a/frontend/src/features/streaming/AirPage.tsx +++ b/frontend/src/features/streaming/AirPage.tsx @@ -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: () => diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 412339d..0c40a48 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -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',