Compare commits

1 Commits
Author SHA1 Message Date
gitea-actions 19e7a97f42 coverage badge [skip ci] 2026-07-27 00:05:30 +00:00
1032 changed files with 138 additions and 147259 deletions
-13
View File
@@ -1,13 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}
-22
View File
@@ -1,22 +0,0 @@
# Базовые правила для редакторов — чтобы IDE не ставила свои отступы и концы строк до того, как
# файл дойдёт до форматтера. Итоговый стиль всё равно задают csharpier (.config/dotnet-tools.json)
# и Prettier (frontend/.prettierrc.json); значения здесь совпадают с их настройками намеренно —
# оба читают .editorconfig, и расхождение развернуло бы форматирование в другую сторону.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
max_line_length = 100
[*.cs]
indent_size = 4
[*.md]
# В markdown два пробела в конце строки — это перенос, обрезать их нельзя.
trim_trailing_whitespace = false
-118
View File
@@ -1,118 +0,0 @@
# TeleWave — пример переменных окружения.
# Скопируй в .env и заполни значения. Ключи вида Section__Key биндятся в IOptions<T> ASP.NET Core.
# ВСЕ значения-секреты ниже обязательно заменить перед запуском (особенно *_PASSWORD, *SigningKey).
# ── PostgreSQL (внешний сервер, не в compose) ─────────────────────────────
# База и пользователь создаются заранее на самом сервере БД (см. README — раздел "Развёртывание").
ConnectionStrings__Default=Host=10.10.1.134;Port=5432;Database=telewave;Username=telewave;Password=change-me-strong-db-password
# ── JWT ───────────────────────────────────────────────────────────────────
Jwt__Issuer=TeleWave
Jwt__Audience=TeleWave
Jwt__SigningKey=change-me-min-32-chars-random-secret
Jwt__AccessTokenMinutes=15
Jwt__RefreshTokenDays=30
# ── Сид администратора (создаётся при первом старте, если не существует) ───
# Логин в систему — по username. Email в системе не используется.
AdminSeed__Username=admin
AdminSeed__Password=change-me-strong-admin-password
# ── Rate limiting ────────────────────────────────────────────────────────
# Лимит запросов/мин на auth-эндпоинты (login/register/refresh). По умолчанию 20.
# RateLimiting__AuthPermitLimit=20
# ── Хранилище медиа ────────────────────────────────────────────────────────
# RootPath — путь ВНУТРИ контейнера; на хосте это bind-mount тома (см. docs/server-storage-setup.md
# и volumes в docker-compose.yml). Не меняй RootPath без синхронного изменения тома.
Storage__RootPath=/media
Storage__SegmentSeconds=2
# Сколько сегментов держать в скользящем окне live-плейлиста.
Storage__LiveWindowSegments=10
Storage__KeepOriginals=false
# Порог свободного места, ниже которого загрузка отклоняется (10 ГБ).
Storage__MinFreeSpaceBytes=10737418240
# Через сколько часов простоя убирать брошенное в uploads/ (след оборванной заливки или рестарта
# посреди неё). Идущую загрузку не задевает — отсчёт от времени последней записи. 0 — не убирать.
# Storage__StaleUploadHours=24
# TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после
# блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30.
# Storage__StreamTokenMinutes=30
# ── Планировщик эфира ──────────────────────────────────────────────────────
# HorizonDays — на сколько суток вперёд держать материализованное расписание. Неделя — часть замысла:
# «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
# RetentionDays — сколько суток прошедшей ленты хранить. Окно обязано покрывать самое долгое остывание
# и самый глубокий повтор среди правил канала: история показов берётся из самой ленты, отдельного
# журнала нет — укоротишь окно, и остывание с потолком повторов начнут «забывать» показы.
# TickMinutes — период фонового тика, достраивающего горизонт.
Scheduler__HorizonDays=7
Scheduler__RetentionDays=90
Scheduler__TickMinutes=30
# ── Обработка медиа (ffmpeg) ───────────────────────────────────────────────
Media__FfmpegPath=/usr/bin/ffmpeg
Media__FfprobePath=/usr/bin/ffprobe
# Максимальный размер загружаемого файла (20 ГБ).
Media__MaxUploadBytes=21474836480
# Потоки одного ffmpeg (0 — авто/все ядра).
Media__TranscodeThreads=3
# Сколько файлов транскодировать одновременно (1 — по умолчанию, как раньше).
# Тюнинг под ядра: обычно TranscodeThreads × MaxParallelTranscodes ≈ число ядер.
# 4 vCPU: Threads=3, Parallel=1 (или Threads=2, Parallel=2)
# 8 vCPU: Threads=4, Parallel=2 ← лучший throughput на пачках серий
# (или Threads=6, Parallel=1 — если важнее скорость одного файла)
# Память: один 1080p-транскод ~0.5 ГБ; Parallel=2 требует ~1 ГБ + база API.
Media__MaxParallelTranscodes=1
# Период опроса каталога inbox/ сканером, секунды.
Media__InboxScanSeconds=15
# Заводить фильм по файлу из inbox/, когда имя уверенно опознано в источнике метаданных.
# Неуверенное совпадение остаётся человеку: файл станет ассетом без шоу и попадёт в разбор.
Media__InboxAutoMovies=true
# Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости.
Media__NormalizeLoudness=true
# Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить).
Media__LoudnessTargetLufs=-16
# Таймауты вызовов ffprobe/ffmpeg, секунды (0 — без таймаута): не дают зависшему процессу вечно
# держать слот параллелизма/тик планировщика. Поднять TranscodeTimeoutSeconds для очень длинных файлов.
# Media__ProbeTimeoutSeconds=120
# Media__TranscodeTimeoutSeconds=1800
# Предпочитаемые языки аудиодорожек ПО УМОЛЧАНИЮ (через запятую, в порядке приоритета) — если файл
# многоозвучный и есть дорожка с таким языком, при обработке берётся она. Обычно задаётся в админке
# («Настройки»); этот конфиг — дефолт, если в настройках пусто. Пусто — выбор ffmpeg по умолчанию.
# Media__PreferredAudioLanguages=rus,eng
# ── ТВ-заставки «Сейчас/Далее» (bumpers) ───────────────────────────────────
# Общие для всех каналов параметры рендера. Оформление и правила (цвета, подписи, длительность,
# интервал, только-между-разными) настраиваются на каждом канале в админке.
# Bumpers__Width=1280
# Bumpers__Height=720
# Bumpers__FontFileSans=/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf
# Bumpers__FontFileSerif=/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf
# Меняй при правке ЛОГИКИ рендера, чтобы пересобрать уже отрендеренные заставки.
# Bumpers__TemplateVersion=1
# ── Метаданные шоу/серий (TMDb / OMDb) ─────────────────────────────────────
# Ключи бесплатные: TMDb — themoviedb.org (Settings → API), OMDb — omdbapi.com.
# Без ключа источник просто не показывается в админке (ручной режим работает всегда).
# TMDb принимает оба вида учётных данных, различать их не нужно — подставляй что есть:
# «API Key (v3 auth)» — 32 hex-символа, уходит параметром api_key;
# «API Read Access Token (v4 auth)» — длинный JWT с точками, уходит заголовком Bearer.
Metadata__Language=ru-RU
Metadata__Tmdb__ApiKey=
Metadata__Omdb__ApiKey=
# Страны возрастной сертификации TMDb в порядке приоритета: берётся первая, у которой рейтинг есть.
# US впереди намеренно — его шкала (G/PG/PG-13/R/NC-17) совпадает с нашей и переводится точно;
# RU (0+…18+) приводится приблизительно и нужен ради покрытия там, где US-сертификации нет.
# Metadata__Tmdb__CertificationCountries__0=US
# Metadata__Tmdb__CertificationCountries__1=RU
# ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_HTTP_PORTS=8080
# ── Доверенные прокси (X-Forwarded-For/Proto) ──────────────────────────────
# TLS терминируется вне compose внешним прокси/шлюзом. По умолчанию доверяется только loopback.
# Если прокси стоит не на loopback, перечисли его через запятую.
# ForwardedHeaders__KnownProxies=203.0.113.10
# ForwardedHeaders__KnownNetworks=172.18.0.0/16
-15
View File
@@ -1,15 +0,0 @@
# Коммиты, которые правили только оформление: перевод концов строк в LF и первые прогоны
# csharpier/Prettier. Без этого списка `git blame` на половине файлов показывал бы их вместо
# автора реальной правки.
#
# Включить локально (в .git/config, а не в репозитории — потому команду надо выполнить у себя):
# git config blame.ignoreRevsFile .git-blame-ignore-revs
#
# Добавлять сюда только чисто механические коммиты, полным SHA, по одному на строку:
# git rev-parse HEAD >> .git-blame-ignore-revs
# Перевод дерева в LF.
0442056367cb6cd3b38964dacf75f44f123e11ac
# Первый прогон Prettier по фронтенду.
0606ea3e6ee4a4fd7faf8d1b054a316633442e98
-10
View File
@@ -1,10 +0,0 @@
# Концы строк в репозитории — всегда LF. Бэкенд и так лежал в LF, фронтенд — в CRLF; Prettier
# (endOfLine: lf по умолчанию) выровнял его, и этот файл не даёт разъехаться обратно: без него
# при core.autocrlf=true у другого разработчика CRLF вернулись бы в коммит.
* text=auto eol=lf
# Бинарники не трогаем.
*.png binary
*.jpg binary
*.ico binary
*.woff2 binary
-170
View File
@@ -1,170 +0,0 @@
name: ci
# Один workflow вместо трёх: порядок build → tests → sonar задаётся через `needs`, а между
# отдельными файлами workflow'ов в Gitea связать этапы нечем. Побочный эффект — этапы идут
# последовательно, поэтому прогон дольше, зато тесты не стартуют на заведомо битой сборке,
# а анализ не уходит в SonarCloud с красными тестами.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
build-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
# Форматирование проверяем до сборки: падает за секунду и сразу показывает, что чинить.
# Версия csharpier прибита в .config/dotnet-tools.json — глобальная копия разработчика на CI
# не влияет, иначе её минорка переформатировала бы репозиторий в свой стиль.
- name: Format check
working-directory: backend
run: |
dotnet tool restore
dotnet csharpier check .
# Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
- name: Build (Release)
working-directory: backend
run: dotnet build TeleWave.slnx -c Release --no-restore
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Enable pnpm
run: corepack enable
- name: Install
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Format check
working-directory: frontend
run: pnpm format:check
- name: Lint
working-directory: frontend
run: pnpm lint
- name: Typecheck
working-directory: frontend
run: pnpm typecheck
- name: Build
working-directory: frontend
run: pnpm build
tests:
runs-on: ubuntu-latest
needs: [build-backend, build-frontend]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
# Покрытие здесь не собираем: его считает джоба sonar своим прогоном (в формате opencover),
# а бейдж покрытия в README берётся из SonarCloud.
- name: Test
working-directory: backend
run: >
dotnet test TeleWave.slnx
-c Release
--no-restore
--logger "console;verbosity=normal"
# Анализ SonarCloud вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты
# и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана
# на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте.
sonar:
runs-on: ubuntu-latest
needs: tests
if: github.event_name != 'pull_request'
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST: https://sonarcloud.io
SONAR_PROJECT_KEY: mrleo1nid_telewave
SONAR_ORGANIZATION: mrleo1nid
steps:
- uses: actions/checkout@v4
with:
# Полная история — иначе Sonar не сопоставит изменения с авторами и «новым кодом».
fetch-depth: 0
# Сканер — java-приложение, на runner-образе Java может не оказаться.
- uses: actions/setup-java@v4
if: env.SONAR_TOKEN != ''
with:
distribution: temurin
java-version: 21
- uses: actions/setup-dotnet@v4
if: env.SONAR_TOKEN != ''
with:
dotnet-version: 10.0.x
- name: Install scanner
if: env.SONAR_TOKEN != ''
run: dotnet tool install --global dotnet-sonarscanner
# Сканер запускается из корня репозитория, а не из backend/: так в анализ попадает и фронт.
- name: Begin analysis
if: env.SONAR_TOKEN != ''
run: |
export PATH="$PATH:$HOME/.dotnet/tools"
dotnet sonarscanner begin \
/k:"$SONAR_PROJECT_KEY" \
/o:"$SONAR_ORGANIZATION" \
/d:sonar.host.url="$SONAR_HOST" \
/d:sonar.token="$SONAR_TOKEN" \
/d:sonar.cs.opencover.reportsPaths="**/coverage/**/coverage.opencover.xml" \
/d:sonar.exclusions="frontend/node_modules/**,frontend/dist/**,frontend/src/routeTree.gen.ts,backend/src/TeleWave.Infrastructure/Migrations/**" \
/d:sonar.coverage.exclusions="backend/src/TeleWave.Infrastructure/Migrations/**,frontend/**" \
/d:sonar.cpd.exclusions="frontend/src/shared/lib/locales/**"
# Локали исключены из поиска дублей намеренно: ru.ts и en.ts обязаны совпадать ключ в ключ —
# это не копипаста, а единственный способ не потерять перевод, и «устранение» такого дубля
# означало бы отказ от второго языка.
# Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true —
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
# сборка живёт в джобе build-backend, здесь она нужна только как носитель анализа.
- name: Build
if: env.SONAR_TOKEN != ''
run: dotnet build backend/TeleWave.slnx -c Release /p:TreatWarningsAsErrors=false
# Формат opencover, а не cobertura по умолчанию: C#-анализатор Sonar читает именно его.
# Интеграционные тесты без Docker пропускаются сами (см. PostgresFixture).
#
# Проекты гоняются по одному, а не одним прогоном по решению: при общем прогоне сборщик
# покрытия теряет попадания — отчёт приезжает без модуля TeleWave.Application, и в Sonar
# покрытые тестами файлы выглядят нулевыми. Отчёты складываются в один каталог, сканер
# подбирает их маской.
- name: Test + coverage
if: env.SONAR_TOKEN != ''
run: |
for project in Domain Application Integration; do
dotnet test "backend/tests/TeleWave.$project.Tests" \
-c Release \
--no-build \
--collect:"XPlat Code Coverage;Format=opencover" \
--results-directory backend/coverage \
--logger "console;verbosity=normal"
done
- name: End analysis
if: env.SONAR_TOKEN != ''
run: |
export PATH="$PATH:$HOME/.dotnet/tools"
dotnet sonarscanner end /d:sonar.token="$SONAR_TOKEN"
- name: Skipped
if: env.SONAR_TOKEN == ''
run: echo "SONAR_TOKEN не задан — анализ пропущен."
-91
View File
@@ -1,91 +0,0 @@
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# ---> VisualStudio
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
.vs/
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.VisualState.xml
TestResult.xml
nunit-*.xml
BenchmarkDotNet.Artifacts/
project.lock.json
project.fragment.lock.json
artifacts/
*.pdb
*.log
*.tlog
# NuGet Packages
*.nupkg
*.snupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
# Others
*.pfx
*.publishsettings
# Node
node_modules/
dist/
dist-ssr/
# Local environment files (secrets) — keep .env.example, ignore real .env
.env
.env.local
.env.*.local
# Локальное медиахранилище для dev-запуска (Storage__RootPath в appsettings.Development.json)
.dev-media/
# Отчёты покрытия (артефакт dotnet test / CI)
coverage/
TestResults/
# ---> TeleWave
# Отладочные дампы каналов: архивы кладёт сюда бэкенд (секция Debug:ExportDirectory).
debug-exports/*
!debug-exports/.gitkeep
-128
View File
@@ -1,128 +0,0 @@
# CLAUDE.md
Инструкции для Claude Code при работе в этом репозитории.
## Что это
**TeleWave** — сервис онлайн-каналов: зритель смотрит сетку каналов, видео отдаётся из хранилища
на сервере. Админ ведёт библиотеку, каналы и эфир.
Работает: аутентификация, роли, пользователи; библиотека шоу/серий, коллекции, жанры, метаданные
(TMDb/OMDb), обработка медиа (ffmpeg → HLS), изображения; группы, шаблон сетки, стыки и заставки,
автосборка по профилям, обмен конфигурацией файлом и запросом к ИИ; генерация ленты, live-раздача,
публичный просмотр, статистика хранилища. Telegram-бот расписания (подписки на оповещения по каналам, прокси, опрос или вебхук). Конвенции — зеркало
[`PnvPanel`](../PnvPanel) (тот же автор).
**Спеки в `docs/` — источник правды по домену**, читай до правок в подсистеме:
[`tv-scheduler-architecture.md`](docs/tv-scheduler-architecture.md) (группы, сетка, слоты, стыки,
заставки, планировщик, автосборка, обмен конфигурацией — главный документ),
[`media-storage-and-streaming.md`](docs/media-storage-and-streaming.md) и
[`server-storage-setup.md`](docs/server-storage-setup.md) (медиа и хранилище).
## Стек
- **Backend**: C# / .NET 10, ASP.NET Core Minimal API, Clean Architecture, CQRS через
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (лёгкая альтернатива MediatR с явным
разделением Command/Query — см. README). EF Core 10 + Npgsql, Identity + JWT, FluentValidation,
Serilog, маппинг DTO вручную, OpenAPI + Scalar UI, тесты на xUnit + NSubstitute.
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router (файловые роуты, `routeTree.gen.ts`
генерируется сборкой), shadcn поверх Radix + Tailwind v4, Zustand (только auth+тема),
react-hook-form + zod, i18n (ru/en), pnpm + oxlint + prettier.
## Архитектура — жёсткие правила
Зависимости строго внутрь: **Api → Infrastructure → Application → Domain**.
- **Domain** — без внешних зависимостей: `Auth`, `Library` (шоу, серии, коллекции, жанры), `Media`,
`Images`, `Programming` (группы, сетка, слоты, стыки + **чистый планировщик** `Planning`),
`Broadcast` (каналы, заставки, лента, live), `Settings`. Модель rich: приватные сеттеры,
фабрики `Create`, поведенческие методы.
- **Application** — CQRS-хендлеры, DTO, валидаторы и **порты** (`Common/Interfaces/I*.cs`:
`IAppDbContext`, `ICurrentUser`, `IMediaProcessor`, `IMediaStorage`, `IBumperRenderer`,
`IMetadataProvider`, `IImageStore`, `IStorageInspector`, …). Зависит только от Domain: никаких
`Npgsql`/`AspNetCore.Identity`, лишь их абстракции.
- **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext<AppUser,
AppRole, Guid>`, конфигурации в `Persistence/Configurations`), ffmpeg, хранилище, метаданные,
идемпотентный `DbInitializer` (роли + админ из env `AdminSeed__*`).
- **Api** — Minimal API (`Endpoints/*Endpoints.cs`), middleware, DI composition root (`Program.cs`).
Обязательно:
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер —
`ISender`/`ICommandHandler`/`IQueryHandler`, регистрация в `Application/DependencyInjection.cs`.
- Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями. Валидация —
FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP; никаких `.Result`/`.Wait()`.
Nullable включён, `TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать.
- Планировщик (`Domain/Programming/Planning`) — **чистая функция**: вход `PlanningInput`, никаких
запросов и часов внутри. Данные под него собирает `Application/Programming/Planning`.
## Инварианты, которые легко нарушить
- **Роли** динамические, у пользователя ровно одна; системные `admin`/`user` (`IsSystem=true`) не
переименовать и не удалить, последнего админа не разжаловать, себя не заблокировать и не удалить.
- **Регистрация открытая** (создаётся `user`, токены сразу); понадобится модерация — заводи отдельный
флаг, не переиспользуй `IsBlocked`. Вход по `UserName`, email и SMTP не используются; JWT access +
refresh в httpOnly `tw_refresh_token` с обнаружением повторного использования отозванного.
- **Правка сетки эфир не двигает**: команда поднимает ревизию шаблона, ленту пересобирает применение.
- **Вещательные сутки** начинаются с `Channel.DayStartTime` (06:00), сетка задаётся во времени
канала (`UtcOffsetMinutes`), в базе всё в UTC.
## Единый контейнер
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`), один
origin, база API — относительный `/api`. Multi-stage Dockerfile: node → dotnet sdk → aspnet.
Отдельный nginx для статики не вводить — ломает требование единого контейнера.
- Работает под непривилегированным `USER $APP_UID` (uid/gid 1654) **статически**, без
gosu/entrypoint: права на bind-mount выставляет оператор (`docs/server-storage-setup.md` § 5).
Data Protection не заводим — шифруемых секретов на диске нет. **TLS внешний**: `app` отдаёт HTTP
и доверяет `X-Forwarded-*`.
- docker-compose: только `app`. PostgreSQL внешний (`ConnectionStrings__Default` из `.env`), база
и пользователь создаются заранее вручную; миграции применяются на старте (`ApplyMigrationsAsync`),
прав `CREATEDB` не нужно.
## Соглашения по коду
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (где есть что проверить),
папки по фичам (`Auth/Login/`, `Programming/Templates/Generate/`, …). Один публичный тип на файл =
имя файла, кроме `Body` — тела Api-эндпоинтов, не совпадающие с командой 1:1 (`sealed record`
внизу файла эндпоинта). DTO — суффикс `Dto`.
- Ошибки — каталоги `XErrors` (`Error.Validation/NotFound/Conflict/…`), наружу единым
`application/problem+json` (`Api/Common/ResultExtensions.cs`). Секреты не логировать.
- **Изображения — только через реестр `Image`** (`Domain/Images`, галерея `features/admin/images`):
картинка хранится как `ImageId` и выбирается контролом `ImageGallery`/`GalleryBrowser`, своих
файловых полей не заводить. Файлы — `images/{id}{ext}`, отдаются по `/api/images/{id}`.
- Фронт: фича = папка в `src/features/admin/<фича>`; тексты только через i18n, ключи в `ru.ts` и `en.ts`.
- Комментарии объясняют **почему**, а не что; по-русски, как в существующем коде.
## Команды
Backend (из `backend/`):
```bash
dotnet build
dotnet csharpier format . # только через dotnet (версия из .config/); в CI — csharpier check
dotnet test tests/TeleWave.Domain.Tests # по одному проекту: MSBuild не берёт несколько
dotnet test tests/TeleWave.Application.Tests
dotnet test tests/TeleWave.Integration.Tests # Testcontainers; без Docker пропускаются
dotnet run --project src/TeleWave.Api
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
```
Frontend (из `frontend/`):
```bash
pnpm install && pnpm dev
pnpm lint && pnpm typecheck && pnpm build
pnpm format && pnpm format:check # CI падает на неотформатированном — прогоняй после правок
```
Инфраструктура: `docker compose up -d --build` (только api; postgres внешний). Окружение — Windows,
оболочка **PowerShell**; для POSIX-скриптов есть Bash-инструмент.
## Рабочие принципы
- Базовый срез реализован — правь по месту. Новые крупные направления с непринятыми решениями
(многоэкземплярность, транскод-профили, подсистемы) — после сверки; неоднозначность — вопрос.
- Меняешь поведение подсистемы — обнови её спеку в `docs/` тем же заходом.
- Соблюдай границы слоёв — главный инвариант проекта.
- Правь файлы через Edit/Write: пользователь видит дифф, а harness не даст перезаписать файл вслепую.
Скриптом на python — только при крайней необходимости (массовая механическая замена), и сказать явно.
- Не коммить и не пуши без явной просьбы; отвечай пользователю на русском.
-48
View File
@@ -1,48 +0,0 @@
# syntax=docker/dockerfile:1
# ── Stage 1: сборка фронтенда (Vite → dist) ───────────────────────────────
FROM node:22-alpine AS frontend
WORKDIR /app/frontend
RUN corepack enable
# pnpm-workspace.yaml здесь не про воркспейсы: в pnpm 11 там живут overrides, и без него
# --frozen-lockfile падает на несовпадении конфигурации с локом.
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
# --ignore-scripts: ни один пакет фронта не требует postinstall, поэтому lifecycle-скрипты зависимостей
# в сборке не выполняются — компрометация любого пакета в дереве не даёт исполнения кода на этапе install.
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile --ignore-scripts
COPY frontend/ ./
RUN pnpm build
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
WORKDIR /src
COPY backend/Directory.Build.props backend/Directory.Packages.props ./backend/
COPY backend/src/TeleWave.Domain/TeleWave.Domain.csproj ./backend/src/TeleWave.Domain/
COPY backend/src/TeleWave.Application/TeleWave.Application.csproj ./backend/src/TeleWave.Application/
COPY backend/src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj ./backend/src/TeleWave.Infrastructure/
COPY backend/src/TeleWave.Api/TeleWave.Api.csproj ./backend/src/TeleWave.Api/
RUN dotnet restore backend/src/TeleWave.Api/TeleWave.Api.csproj
COPY backend/ ./backend/
# Статика собранного фронта → wwwroot (перекрывает заглушку)
COPY --from=frontend /app/frontend/dist/ ./backend/src/TeleWave.Api/wwwroot/
RUN dotnet publish backend/src/TeleWave.Api/TeleWave.Api.csproj -c Release -o /app/publish --no-restore
# ── Stage 3: runtime ──────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# curl — для HEALTHCHECK; ffmpeg — нормализация и нарезка медиа при загрузке (см. Media__FfmpegPath);
# fonts-dejavu-core — кириллический TTF для текста на ТВ-заставках (drawtext, см. Bumpers__FontFile).
RUN apt-get update && apt-get install -y --no-install-recommends curl ffmpeg fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
COPY --from=backend /app/publish ./
# Работаем под непривилегированным пользователем образа aspnet (app, uid/gid 1654 — $APP_UID задан
# в самом образе). Порт 8080 непривилегированный, установка пакетов уже позади, приложению нужна
# только запись в /media. ВАЖНО: bind-mount пробрасывает права хоста как есть — каталог хранилища
# должен принадлежать uid 1654, иначе контейнер не сможет писать (см. docs/server-storage-setup.md § 5).
USER $APP_UID
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "TeleWave.Api.dll"]
-86
View File
@@ -1,86 +0,0 @@
# TeleWave
[![ci](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions/workflows/ci.yml/badge.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml)
[![quality gate](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=alert_status&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=coverage&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=bugs&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=code_smells&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=security_rating&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=mrleo1nid_telewave&metric=sqale_rating&token=fa38966b51f0043827fa89a5e42a0afe20180758)](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
хранилища на сервере, админ управляет каналами и пользователями.
> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка;
> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS), метаданные (TMDb/OMDb), каталог
> каналов, планировщик эфира (реклама, ТВ-заставки, weekly-override'ы) и live-раздача HLS с публичным
> просмотром сетки. Приложение (фронт + бек) поставляется **единым Docker-образом**; PostgreSQL —
> **внешний**, не поднимается через compose.
## Стек
| Слой | Технологии |
| -------- | ---------------------------------------------------------------------------------------- |
| Backend | C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS ([LiteCqrs.Net](https://github.com/mrleo1nid/LiteCqrs.Net)), EF Core |
| БД | PostgreSQL (Npgsql) |
| Auth | ASP.NET Core Identity + JWT (access + refresh, ротация) |
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind v4 (ретро-CRT тема) |
| Упаковка | Единый Docker-образ (API + статика SPA); PostgreSQL — внешний сервер |
## Развёртывание
Postgres не входит в compose — база и пользователь создаются заранее на внешнем сервере БД:
```sql
-- на сервере БД (psql -h <хост БД> -U postgres)
CREATE USER telewave WITH PASSWORD 'change-me-strong-db-password';
CREATE DATABASE telewave OWNER telewave;
```
Затем на хосте с приложением:
```bash
cp .env.example .env
# заполнить ConnectionStrings__Default (хост/база/юзер/пароль БД), Jwt__SigningKey, AdminSeed__*
docker compose up -d --build
# → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password)
```
**Права на медиахранилище.** Контейнер работает под непривилегированным пользователем (uid/gid
`1654`), а bind-mount пробрасывает права хоста как есть — каталог хранилища должен принадлежать
этому uid, иначе приложение стартует, но любая запись в `/media` упадёт с `Permission denied`.
Полная процедура (включая группу, через которую вы сами кладёте файлы в `inbox/` и `manual/`) —
[`docs/server-storage-setup.md`](docs/server-storage-setup.md), шаг 5:
```bash
sudo groupadd -g 1654 telewave
sudo usermod -aG telewave "$USER" # затем перелогиниться
sudo chown -R 1654:1654 /srv/telewave/media
sudo chmod -R 750 /srv/telewave/media
sudo chmod 2775 /srv/telewave/media/inbox /srv/telewave/media/manual
```
> Обновляетесь с версии, где контейнер работал под root? Это и есть вся миграция: остановите
> контейнер (`docker compose down`), выполните команды выше, поднимайте новый образ.
## Локальная разработка
```bash
# backend, из backend/
dotnet build && dotnet test tests/TeleWave.Domain.Tests tests/TeleWave.Application.Tests
dotnet run --project src/TeleWave.Api
# frontend, из frontend/
pnpm install
pnpm dev # проксирует /api на localhost:8080
```
## Документация
Пример переменных окружения (сид админа, БД, JWT) — [`.env.example`](.env.example).
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md) (стек, архитектурные
правила, доменные инварианты, соглашения по коду).
## Лицензия
Не определена.
-70
View File
@@ -1,70 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0F9113EE-888A-26D2-68B0-4A7D0A2A8745}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Api", "backend\src\TeleWave.Api\TeleWave.Api.csproj", "{637772B7-C13F-21C4-0C66-BA606166F604}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Application", "backend\src\TeleWave.Application\TeleWave.Application.csproj", "{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Domain", "backend\src\TeleWave.Domain\TeleWave.Domain.csproj", "{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Infrastructure", "backend\src\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj", "{9D8E3873-E632-F209-1AA0-619B3FC683AC}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F57642F3-C37C-D174-720E-6A6AAD5BEE22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Application.Tests", "backend\tests\TeleWave.Application.Tests\TeleWave.Application.Tests.csproj", "{E27F38DB-A6A4-A33F-0C57-9885DD209034}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeleWave.Domain.Tests", "backend\tests\TeleWave.Domain.Tests\TeleWave.Domain.Tests.csproj", "{EA5A4356-A5CE-7650-74A5-53D50CB50872}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{637772B7-C13F-21C4-0C66-BA606166F604}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{637772B7-C13F-21C4-0C66-BA606166F604}.Debug|Any CPU.Build.0 = Debug|Any CPU
{637772B7-C13F-21C4-0C66-BA606166F604}.Release|Any CPU.ActiveCfg = Release|Any CPU
{637772B7-C13F-21C4-0C66-BA606166F604}.Release|Any CPU.Build.0 = Release|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6}.Release|Any CPU.Build.0 = Release|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE}.Release|Any CPU.Build.0 = Release|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9D8E3873-E632-F209-1AA0-619B3FC683AC}.Release|Any CPU.Build.0 = Release|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E27F38DB-A6A4-A33F-0C57-9885DD209034}.Release|Any CPU.Build.0 = Release|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EA5A4356-A5CE-7650-74A5-53D50CB50872}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{0F9113EE-888A-26D2-68B0-4A7D0A2A8745} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
{637772B7-C13F-21C4-0C66-BA606166F604} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{128E7BA6-6F6C-30A8-1C85-66DDE98604B6} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{5157D309-1C3D-BE91-5E8F-42B9CBB9AFBE} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{9D8E3873-E632-F209-1AA0-619B3FC683AC} = {0F9113EE-888A-26D2-68B0-4A7D0A2A8745}
{F57642F3-C37C-D174-720E-6A6AAD5BEE22} = {1AE8ACA6-933B-BF2A-3671-3E2EAC007D16}
{E27F38DB-A6A4-A33F-0C57-9885DD209034} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
{EA5A4356-A5CE-7650-74A5-53D50CB50872} = {F57642F3-C37C-D174-720E-6A6AAD5BEE22}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {CD8641E5-D878-4C5E-BE1C-314F2280EC6E}
EndGlobalSection
EndGlobal
-26
View File
@@ -1,26 +0,0 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<!--
CA1859 (сменить интерфейс коллекции на конкретный тип ради девиртуализации) — осознанно
выключено: помечаются им границы API (доменные модели планировщика, порты, хелперы тестов),
и «ускорение» там означает изменяемую коллекцию, протекающую через границу. Пути не горячие
(генерация расписания идёт раз в тик, а не в цикле по кадрам). Понадобится — включать точечно
по месту, а не глобально.
ASP0018 (параметр маршрута не используется хендлером) — тоже осознанно: часть шаблонов
объявляет сегменты ради формы URL, а не ради хендлера. Так, превью заставки адресуется
подблоком, но канал и блок остаются в пути, потому что по ним строит ссылки плейлист;
у /{slug}/watch токен подтверждает зрителя, а не подписку на канал.
-->
<NoWarn>$(NoWarn);CA1711;CA1716;CA1848;CA1873;CA1859;ASP0018</NoWarn>
</PropertyGroup>
</Project>
-49
View File
@@ -1,49 +0,0 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion
Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"
Version="10.0.10"
/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<!-- Держим 2.x: Microsoft.AspNetCore.OpenApi 10.0.10 сам зависит от Microsoft.OpenApi >=2.0.0
(nuspec) и его Roslyn source generator (XmlCommentGenerator) скомпилирован под 2.x API —
3.x меняет IOpenApiMediaType.Example на read-only и ломает генерацию (CS0200). -->
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageVersion
Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="10.0.10"
/>
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.21.0" />
<PackageVersion Include="LiteCqrs.Net" Version="1.0.1" />
<!-- Тестирование -->
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
</ItemGroup>
</Project>
-13
View File
@@ -1,13 +0,0 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/TeleWave.Api/TeleWave.Api.csproj" />
<Project Path="src/TeleWave.Application/TeleWave.Application.csproj" />
<Project Path="src/TeleWave.Domain/TeleWave.Domain.csproj" />
<Project Path="src/TeleWave.Infrastructure/TeleWave.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/TeleWave.Domain.Tests/TeleWave.Domain.Tests.csproj" />
<Project Path="tests/TeleWave.Application.Tests/TeleWave.Application.Tests.csproj" />
<Project Path="tests/TeleWave.Integration.Tests/TeleWave.Integration.Tests.csproj" />
</Folder>
</Solution>
@@ -1,4 +0,0 @@
namespace TeleWave.Api.Common;
/// <summary>Единый ответ на создание сущности — её идентификатор.</summary>
public sealed record CreatedIdResponse(Guid Id);
@@ -1,6 +0,0 @@
namespace TeleWave.Api.Common;
public static class RateLimiting
{
public const string AuthPolicy = "auth";
}
@@ -1,27 +0,0 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Api.Common;
public static class ResultExtensions
{
public static IResult ToHttpResult(this Result result) =>
result.IsSuccess ? Results.NoContent() : ToProblem(result.Error);
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess ? Results.Ok(result.Value) : ToProblem(result.Error);
public static IResult ToProblem(this Error error)
{
var statusCode = error.Type switch
{
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
ErrorType.Forbidden => StatusCodes.Status403Forbidden,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status422UnprocessableEntity,
};
return Results.Problem(title: error.Code, detail: error.Message, statusCode: statusCode);
}
}
@@ -1,34 +0,0 @@
using System.Text.RegularExpressions;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Common;
/// <summary>
/// Общая проверка файлов нарезки для всех эндпоинтов, отдающих HLS: эфир, превью заставок.
/// Имя сегмента сверяется с allowlist, а путь резолвится через <see cref="MediaPathResolver"/>,
/// который бросает <see cref="UnauthorizedAccessException"/> на попытку выйти за пределы каталога —
/// наружу это должно выглядеть как обычный 404, а не как ошибка сервера.
/// </summary>
internal static partial class SegmentFiles
{
[GeneratedRegex(@"^seg\d{1,6}\.ts$")]
private static partial Regex SegmentName();
public static bool IsSegmentName(string file) => SegmentName().IsMatch(file);
/// <summary>Путь к существующему файлу нарезки, либо null — если имя опасно или файла нет.</summary>
public static string? TryResolveExisting(MediaPathResolver paths, Guid assetId, string fileName)
{
string path;
try
{
path = paths.SegmentPath(assetId, fileName);
}
catch (UnauthorizedAccessException)
{
return null;
}
return File.Exists(path) ? path : null;
}
}
@@ -1,18 +0,0 @@
using Microsoft.Extensions.Options;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Common;
/// <summary>
/// Лимиты приёма файла, собранные в одно место. Они живут в двух разных секциях конфигурации
/// (<c>Media</c> и <c>Storage</c>), но проверяются всегда вместе и только на входе загрузки —
/// хендлеру незачем знать про обе секции и тащить два <see cref="IOptions{T}"/> в сигнатуре.
/// </summary>
public sealed class UploadLimits(IOptions<MediaOptions> media, IOptions<StorageOptions> storage)
{
/// <summary>Потолок размера загружаемого файла.</summary>
public long MaxUploadBytes { get; } = media.Value.MaxUploadBytes;
/// <summary>Сколько места должно остаться свободным после приёма файла.</summary>
public long MinFreeSpaceBytes { get; } = storage.Value.MinFreeSpaceBytes;
}
@@ -1,137 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Admin.Users.BlockUser;
using TeleWave.Application.Admin.Users.CreateUser;
using TeleWave.Application.Admin.Users.DeleteUser;
using TeleWave.Application.Admin.Users.ListUsers;
using TeleWave.Application.Admin.Users.ResetPassword;
using TeleWave.Application.Admin.Users.UnblockUser;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class AdminUserEndpoints
{
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/users")
.WithTags("Admin.Users")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListUsers).Produces<PagedList<UserSummaryDto>>();
admin.MapPost("", CreateUser).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPost("/{id:guid}/block", BlockUser).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/unblock", UnblockUser).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/password", ResetPassword)
.Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteUser).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListUsers(
[AsParameters] ListUsersFilter filter,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListUsersQuery(
filter.Page is > 0 ? filter.Page.Value : 1,
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
filter.Search,
filter.RoleId,
filter.IsBlocked,
filter.Sort,
filter.Desc ?? false
),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> CreateUser(
CreateUserBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateUserCommand(body.UserName, body.Password, body.RoleId),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/users/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> BlockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new BlockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UnblockUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UnblockUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ResetPassword(
Guid id,
ResetPasswordBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ResetUserPasswordCommand(id, body.NewPassword),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteUser(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteUserCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
/// <summary>
/// Фильтр и страница списка пользователей. Все поля nullable намеренно: обязательный параметр в query
/// превращает запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело. Умолчания
/// подставляет сам хендлер, а не объявление: при <c>AsParameters</c> обязательность определяется
/// nullable-типом, а не наличием значения по умолчанию в конструкторе.
/// </summary>
public sealed record ListUsersFilter(
int? Page,
int? PageSize,
string? Search,
Guid? RoleId,
bool? IsBlocked,
string? Sort,
bool? Desc
);
public sealed record CreateUserBody(string UserName, string Password, Guid RoleId);
public sealed record ResetPasswordBody(string NewPassword);
@@ -1,250 +0,0 @@
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Auth;
using TeleWave.Application.Auth.ChangePassword;
using TeleWave.Application.Auth.ChangeUserName;
using TeleWave.Application.Auth.DeleteMyAccount;
using TeleWave.Application.Auth.Login;
using TeleWave.Application.Auth.Logout;
using TeleWave.Application.Auth.Me;
using TeleWave.Application.Auth.Refresh;
using TeleWave.Application.Auth.Register;
using TeleWave.Application.Settings.GetSiteSettings;
namespace TeleWave.Api.Endpoints;
public static class AuthEndpoints
{
private const string RefreshCookieName = "tw_refresh_token";
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth")
.WithTags("Auth")
.RequireRateLimiting(RateLimiting.AuthPolicy);
group.MapGet("/registration", RegistrationStatus).Produces<RegistrationStatusDto>();
group.MapPost("/register", Register).Produces<AuthResponseDto>();
group.MapPost("/login", Login).Produces<AuthResponseDto>();
group.MapPost("/refresh", Refresh).Produces<AuthResponseDto>();
group
.MapPost("/logout", Logout)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-password", ChangePassword)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group
.MapPost("/change-username", ChangeUserName)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
group.MapGet("/me", Me).RequireAuthorization().Produces<CurrentUserDto>();
group
.MapDelete("/me", DeleteMe)
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> RegistrationStatus(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(new RegistrationStatusDto(settings.RegistrationEnabled));
}
private static async Task<IResult> Register(
RegisterCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt,
UseSecureCookie(request, env)
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Login(
LoginCommand command,
ISender sender,
HttpRequest request,
HttpResponse response,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
SetRefreshCookie(
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt,
UseSecureCookie(request, env)
);
return Results.Ok(ToLoginResponse(result.Value));
}
private static async Task<IResult> Refresh(
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
if (
!request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
|| string.IsNullOrEmpty(rawToken)
)
return Results.Unauthorized();
var secure = UseSecureCookie(request, env);
var result = await sender.Send(new RefreshCommand(rawToken), cancellationToken);
if (!result.IsSuccess)
{
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
return result.ToHttpResult();
}
SetRefreshCookie(
response,
result.Value.RefreshToken,
result.Value.RefreshTokenExpiresAt,
secure
);
return Results.Ok(ToLoginResponse(result.Value));
}
internal static AuthResponseDto ToLoginResponse(AuthResult auth) =>
new(auth.AccessToken, auth.AccessTokenExpiresAt, auth.User);
private static async Task<IResult> Logout(
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
if (
request.Cookies.TryGetValue(RefreshCookieName, out var rawToken)
&& !string.IsNullOrEmpty(rawToken)
)
await sender.Send(new LogoutCommand(rawToken), cancellationToken);
var secure = UseSecureCookie(request, env);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
DeleteStreamCookie(response, secure);
return Results.NoContent();
}
private static async Task<IResult> ChangePassword(
ChangePasswordCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserName(
ChangeUserNameCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Me(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetCurrentUserQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteMe(
HttpRequest request,
HttpResponse response,
ISender sender,
IHostEnvironment env,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMyAccountCommand(), cancellationToken);
var secure = UseSecureCookie(request, env);
response.Cookies.Delete(RefreshCookieName, BuildCookieOptions(secure));
DeleteStreamCookie(response, secure);
return result.ToHttpResult();
}
private static void SetRefreshCookie(
HttpResponse response,
string rawToken,
DateTimeOffset expiresAt,
bool secure
)
{
var options = BuildCookieOptions(secure);
options.Expires = expiresAt;
response.Cookies.Append(RefreshCookieName, rawToken, options);
}
private static CookieOptions BuildCookieOptions(bool secure) =>
new()
{
HttpOnly = true,
Secure = secure,
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
};
/// <summary>Ставить ли cookie с флагом Secure. Вне Development — всегда true: прод работает за
/// внешним TLS-прокси, а <see cref="HttpRequest.IsHttps"/> ненадёжен (при неполной настройке
/// ForwardedHeaders он false, и долгоживущий refresh-cookie ушёл бы без Secure). В Development
/// допускаем HTTP-разработку.</summary>
private static bool UseSecureCookie(HttpRequest request, IHostEnvironment env) =>
!env.IsDevelopment() || request.IsHttps;
/// <summary>Гасит stream-cookie (tw_stream, Path=/api) — иначе после выхода эфир можно смотреть
/// по прямой ссылке ещё до истечения токена.</summary>
private static void DeleteStreamCookie(HttpResponse response, bool secure) =>
response.Cookies.Delete(
"tw_stream",
new CookieOptions
{
HttpOnly = true,
Secure = secure,
SameSite = SameSiteMode.Strict,
Path = "/api",
}
);
}
public sealed record AuthResponseDto(
string AccessToken,
DateTimeOffset ExpiresAt,
CurrentUserDto User
);
/// <summary>Публичный ответ: включена ли открытая регистрация (для страниц входа/регистрации).</summary>
public sealed record RegistrationStatusDto(bool Enabled);
@@ -1,333 +0,0 @@
using System.Text;
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Блоки ТВ-заставок: оформление, звук, подблоки с текстом и рендер превью. Блоки общие для всех
/// каналов, поэтому и раздел свой, не канальный — канал только ссылается на них врезками стыков.
/// </summary>
public static class BumperEndpoints
{
public static IEndpointRouteBuilder MapBumperEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/bumpers")
.WithTags("Admin.Bumpers")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", List).Produces<IReadOnlyList<BumperTemplateDto>>();
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{templateId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{templateId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{templateId:guid}/audio", UploadAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{templateId:guid}/audio", ClearAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{templateId:guid}/background", SetBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{templateId:guid}/background", ClearBackground)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{templateId:guid}/variants", AddVariant)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{templateId:guid}/variants/{variantId:guid}", UpdateVariant)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{templateId:guid}/variants/{variantId:guid}", RemoveVariant)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/index.m3u8", PreviewPlaylist);
admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/{file}", PreviewSegment);
return app;
}
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListBumperTemplatesQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Create(
BumperNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateBumperTemplateCommand(body.Name),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/bumpers/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> Update(
Guid templateId,
UpdateBumperTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateBumperTemplateCommand(
templateId,
new BumperStyle(
body.Name,
body.Font,
body.BackgroundColor,
body.BackgroundColor2,
body.AccentColor,
body.TextColor
)
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Delete(
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new DeleteBumperTemplateCommand(templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadAudio(
Guid templateId,
string fileName,
HttpRequest request,
IBumperTemplateStorage storage,
IAudioProbe probe,
ISender sender,
CancellationToken cancellationToken
)
{
if (ResolveExtension(fileName, request) is not { } ext)
return BumperErrors.InvalidFile.ToProblem();
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
var path = storage.AudioPath(templateId, ext);
var duration = path is null
? null
: await probe.TryGetDurationAsync(path, cancellationToken);
var result = await sender.Send(
new SetBumperTemplateAudioCommand(templateId, ext, duration?.TotalSeconds ?? 0),
cancellationToken
);
if (!result.IsSuccess)
await storage.DeleteAudioAsync(templateId, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearAudio(
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ClearBumperTemplateAudioCommand(templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetBackground(
Guid templateId,
SetBumperBackgroundBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetBumperTemplateBackgroundCommand(templateId, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ClearBackground(
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ClearBumperTemplateBackgroundCommand(templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddVariant(
Guid templateId,
BumperNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddBumperVariantCommand(templateId, body.Name),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/bumpers/{templateId}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateVariant(
Guid templateId,
Guid variantId,
BumperVariantInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateBumperVariantCommand(templateId, variantId, input),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveVariant(
Guid templateId,
Guid variantId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveBumperVariantCommand(templateId, variantId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
private static async Task<IResult> RenderPreview(
Guid templateId,
Guid? channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RenderBumperPreviewCommand(templateId, channelId),
cancellationToken
);
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
}
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid templateId, Guid variantId, MediaPathResolver paths)
{
var previewId = BumperPreview.AssetId(variantId);
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
return Results.NotFound();
var baseUrl = $"/api/admin/bumpers/{templateId}/preview/{variantId}/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Комментарии/директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
/// <summary>
/// Сегмент превью. Блок в маршруте есть, но хендлеру не нужен: каталог превью адресуется
/// подблоком (см. <see cref="BumperPreview.AssetId"/>) — незаявленные параметры не связываются.
/// </summary>
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
{
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
var previewId = BumperPreview.AssetId(variantId);
if (SegmentFiles.TryResolveExisting(paths, previewId, file) is not { } path)
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
private static string? ResolveExtension(string fileName, HttpRequest request)
{
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
return null;
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return BumperFiles.AudioExtensions.Contains(ext) ? ext : null;
}
}
public sealed record BumperNameBody(string Name);
public sealed record UpdateBumperTemplateBody(
string Name,
BumperFont Font,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor
);
public sealed record SetBumperBackgroundBody(Guid ImageId);
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
internal static class BumperFiles
{
public const long MaxBytes = 200L * 1024 * 1024; // 200 МБ
public static readonly IReadOnlySet<string> AudioExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".mp3",
".m4a",
".aac",
".ogg",
".opus",
".wav",
};
}
@@ -1,210 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Application.Broadcast.UpdateViewerSettings;
using TeleWave.Application.Programming.Planning.DebugExport;
using TeleWave.Application.Programming.Planning.Trace;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки
/// и стыки общие для всех каналов и живут своими разделами (<c>BumperEndpoints</c>,
/// <c>JunctionEndpoints</c>); что и когда идёт в эфире, задаёт шаблон сетки (<c>TemplateEndpoints</c>).
/// </summary>
public static class ChannelEndpoints
{
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/channels")
.WithTags("Admin.Channels")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin
.MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
admin
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
.Produces(StatusCodes.Status204NoContent);
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
// Отладочный дамп: вход планировщика и результат одним архивом.
admin.MapPost("/{id:guid}/debug-export", ExportDebug).Produces(StatusCodes.Status200OK);
return app;
}
/// <summary>
/// Отдаёт архив со снимком канала. POST, а не GET: команда оставляет копию дампа на сервере,
/// и кэшировать такой ответ никому не следует.
/// </summary>
private static async Task<IResult> ExportDebug(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ExportChannelDebugCommand(id), cancellationToken);
if (!result.IsSuccess)
return result.ToHttpResult();
return Results.File(result.Value.Content, "application/zip", result.Value.FileName);
}
private static async Task<IResult> UpdateViewerSettings(
Guid id,
UpdateViewerSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateViewerSettingsCommand(
id,
body.LogoImageId,
body.LogoCorner,
body.LogoOpacity,
body.ShowClock,
body.AnalogFilterStrength
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetEntryTrace(
Guid entryId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetChannel(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTime(
Guid id,
UpdateChannelTimeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
Guid id,
UpdateChannelSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelSettingsCommand(
id,
body.Name,
body.IsEnabled,
body.FillerAssetId,
body.IconImageId
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetSchedule(
Guid id,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
}
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
public sealed record UpdateChannelTimeBody(
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime
);
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
Guid? FillerAssetId,
Guid? IconImageId
);
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
@@ -1,257 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library.Collections;
using TeleWave.Application.Library.Collections.AddCollectionShow;
using TeleWave.Application.Library.Collections.AddShowsToCollection;
using TeleWave.Application.Library.Collections.CreateCollection;
using TeleWave.Application.Library.Collections.DeleteCollection;
using TeleWave.Application.Library.Collections.FillCollectionPosters;
using TeleWave.Application.Library.Collections.GetCollection;
using TeleWave.Application.Library.Collections.ListCollections;
using TeleWave.Application.Library.Collections.RemoveCollectionShow;
using TeleWave.Application.Library.Collections.ReorderCollection;
using TeleWave.Application.Library.Collections.SetCollectionPoster;
using TeleWave.Application.Library.Collections.Suggest;
using TeleWave.Application.Library.Collections.UpdateCollection;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class CollectionEndpoints
{
public static IEndpointRouteBuilder MapCollectionEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/collections")
.WithTags("Admin.Collections")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListCollections).Produces<IReadOnlyList<CollectionSummaryDto>>();
admin.MapGet("/{id:guid}", GetCollection).Produces<CollectionDto>();
admin
.MapPost("", CreateCollection)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{id:guid}", UpdateCollection).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteCollection).Produces(StatusCodes.Status204NoContent);
admin.MapPost("/{id:guid}/shows", AddShow).Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/shows/{showId:guid}", RemoveShow)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent);
// Сборка франшиз: массовое добавление отмеченных шоу и предложения по библиотеке.
admin.MapPost("/bulk/shows", AddShows).Produces<AddShowsToCollectionResultDto>();
// Постеры по частям: без списка — всем коллекциям, у которых постера ещё нет.
admin.MapPost("/bulk/posters", FillPosters).Produces<int>();
admin
.MapGet("/suggestions", Suggestions)
.Produces<IReadOnlyList<CollectionSuggestionDto>>();
admin
.MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
// Дополнение уже собранной франшизы: коллекция определяется тем же ключом предложения.
admin.MapPost("/suggestions/add", AddFromSuggestion).Produces<int>();
return app;
}
private static async Task<IResult> FillPosters(
FillCollectionPostersBody? body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new FillCollectionPostersCommand(body?.CollectionIds),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddShows(
AddShowsToCollectionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddShowsToCollectionCommand(
body.ShowIds,
body.CollectionId,
body.NewCollectionName
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Suggestions(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SuggestCollectionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> CreateFromSuggestion(
CreateCollectionFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateCollectionFromSuggestionCommand(body.Key),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/collections/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> AddFromSuggestion(
CreateCollectionFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new AddSuggestedShowsCommand(body.Key), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListCollections(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListCollectionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetCollection(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetCollectionQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateCollection(
CreateCollectionCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/collections/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateCollection(
Guid id,
UpdateCollectionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateCollectionCommand(id, body.Name, body.Description),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteCollection(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteCollectionCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddShow(
Guid id,
CollectionShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddCollectionShowCommand(id, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveShow(
Guid id,
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveCollectionShowCommand(id, showId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Reorder(
Guid id,
ReorderCollectionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ReorderCollectionCommand(id, body.ShowIdsInOrder),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetPoster(
Guid id,
CollectionPosterBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetCollectionPosterCommand(id, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateCollectionBody(string Name, string? Description);
public sealed record CollectionShowBody(Guid ShowId);
public sealed record ReorderCollectionBody(IReadOnlyList<Guid> ShowIdsInOrder);
public sealed record CollectionPosterBody(Guid? ImageId);
/// <summary>Куда складывать: существующая коллекция либо новая по имени.</summary>
public sealed record AddShowsToCollectionBody(
IReadOnlyList<Guid> ShowIds,
Guid? CollectionId = null,
string? NewCollectionName = null
);
public sealed record CreateCollectionFromSuggestionBody(string Key);
public sealed record FillCollectionPostersBody(IReadOnlyList<Guid>? CollectionIds);
@@ -1,162 +0,0 @@
using System.Reflection;
using System.Runtime.InteropServices;
using LiteCqrs;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Options;
using TeleWave.Application.Diagnostics;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админская диагностика. Главное здесь — картина прокси-форвардинга: заголовки, которым доверяет
/// приложение, и итоговая схема/хост, из которых строятся абсолютные ссылки (M3U/EPG). Именно тут
/// видно, почему ссылки уходят по http вместо https: X-Forwarded-Proto пришёл, но сосед не доверен.
/// Плюс живое состояние инфраструктуры (БД, том хранилища, ffmpeg) — через системный запрос.
/// </summary>
public static class DiagnosticsEndpoints
{
public static IEndpointRouteBuilder MapDiagnosticsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/admin/diagnostics", Get)
.WithTags("Admin.Diagnostics")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
.Produces<DiagnosticsResponse>();
return app;
}
private static async Task<IResult> Get(
HttpContext http,
IOptions<ForwardedHeadersOptions> forwardedOptions,
IHostEnvironment env,
ISender sender,
CancellationToken cancellationToken
)
{
var system = await sender.Send(new GetSystemDiagnosticsQuery(), cancellationToken);
var host = BuildHost(env);
var forwarding = BuildForwarding(http, forwardedOptions.Value);
return Results.Ok(new DiagnosticsResponse(host, forwarding, system));
}
private static HostDiagnosticsDto BuildHost(IHostEnvironment env)
{
var assembly = Assembly.GetEntryAssembly();
var version =
assembly
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion
?? assembly?.GetName().Version?.ToString();
return new HostDiagnosticsDto(
version,
env.EnvironmentName,
RuntimeInformation.FrameworkDescription,
RuntimeInformation.OSDescription,
DateTimeOffset.UtcNow
);
}
private static ForwardingDiagnosticsDto BuildForwarding(
HttpContext http,
ForwardedHeadersOptions options
)
{
var request = http.Request;
var scheme = request.Scheme;
var host = request.Host.Value ?? string.Empty;
var isHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase);
var remoteIp = http.Connection.RemoteIpAddress?.ToString();
var forwardedProto = Header(request, "X-Forwarded-Proto");
// X-Original-* заголовки ставит сам ForwardedHeaders-middleware, когда доверяет соседу и
// применяет форвардинг — по их наличию отличаем «применено» от «отброшено как недоверенное».
var originalProto = Header(request, "X-Original-Proto");
var originalRemoteIp = Header(request, "X-Original-For");
var applied = originalProto is not null || originalRemoteIp is not null;
// Что прокси реально прислал (до возможного применения), и вывод с готовой подсказкой.
var sentProto = originalProto ?? forwardedProto;
string verdict;
string? suggestedTrustedIp = null;
if (isHttps)
verdict = "ok";
else if (string.Equals(sentProto, "https", StringComparison.OrdinalIgnoreCase))
{
// Заголовок пришёл, но схема осталась http → непосредственный сосед не в доверенных.
verdict = "proxyNotTrusted";
suggestedTrustedIp = remoteIp;
}
else
verdict = "protoHeaderMissing";
return new ForwardingDiagnosticsDto(
scheme,
host,
$"{scheme}://{host}",
isHttps,
remoteIp,
forwardedProto,
Header(request, "X-Forwarded-For"),
Header(request, "X-Forwarded-Host"),
Header(request, "X-Real-IP"),
originalProto,
originalRemoteIp,
applied,
options.ForwardLimit,
options.ForwardedHeaders.ToString(),
[.. options.KnownProxies.Select(ip => ip.ToString())],
[.. options.KnownIPNetworks.Select(net => net.ToString())],
verdict,
suggestedTrustedIp
);
}
private static string? Header(HttpRequest request, string name) =>
request.Headers.TryGetValue(name, out var value) && !string.IsNullOrEmpty(value)
? value.ToString()
: null;
}
/// <summary>Ответ диагностики: процесс/рантайм, картина форвардинга и живое состояние инфраструктуры.</summary>
public sealed record DiagnosticsResponse(
HostDiagnosticsDto Host,
ForwardingDiagnosticsDto Forwarding,
SystemDiagnosticsDto System
);
public sealed record HostDiagnosticsDto(
string? AppVersion,
string Environment,
string Framework,
string Os,
DateTimeOffset ServerTimeUtc
);
/// <summary>
/// Картина X-Forwarded-*. <c>Verdict</c>: <c>ok</c> — схема https; <c>proxyNotTrusted</c> — прокси
/// прислал https, но <c>Applied=false</c>, и <c>SuggestedTrustedIp</c> нужно внести в KnownProxies;
/// <c>protoHeaderMissing</c> — прокси вообще не шлёт X-Forwarded-Proto.
/// </summary>
public sealed record ForwardingDiagnosticsDto(
string Scheme,
string Host,
string Origin,
bool IsHttps,
string? RemoteIp,
string? XForwardedProto,
string? XForwardedFor,
string? XForwardedHost,
string? XRealIp,
string? OriginalProto,
string? OriginalRemoteIp,
bool Applied,
int? ForwardLimit,
string ForwardedHeaders,
IReadOnlyList<string> KnownProxies,
IReadOnlyList<string> KnownNetworks,
string Verdict,
string? SuggestedTrustedIp
);
@@ -1,77 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library.Genres;
using TeleWave.Application.Library.Genres.CreateGenre;
using TeleWave.Application.Library.Genres.DeleteGenre;
using TeleWave.Application.Library.Genres.ListGenres;
using TeleWave.Application.Library.Genres.UpdateGenre;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class GenreEndpoints
{
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/genres")
.WithTags("Admin.Genres")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListGenres).Produces<IReadOnlyList<GenreDto>>();
admin.MapPost("", CreateGenre).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{id:guid}", UpdateGenre).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteGenre).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListGenres(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListGenresQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> CreateGenre(
CreateGenreCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/genres/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateGenre(
Guid id,
UpdateGenreBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateGenreCommand(id, body.Name, body.SortOrder, body.Aliases),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteGenre(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteGenreCommand(id), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateGenreBody(string Name, int SortOrder, IReadOnlyList<string>? Aliases);
@@ -1,256 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Groups.AddGroupElements;
using TeleWave.Application.Programming.Groups.CreateGroup;
using TeleWave.Application.Programming.Groups.DeleteGroup;
using TeleWave.Application.Programming.Groups.ExcludeGroupElement;
using TeleWave.Application.Programming.Groups.FindGroupCandidates;
using TeleWave.Application.Programming.Groups.GetGroup;
using TeleWave.Application.Programming.Groups.ListGroups;
using TeleWave.Application.Programming.Groups.RemoveGroupItem;
using TeleWave.Application.Programming.Groups.ReorderGroup;
using TeleWave.Application.Programming.Groups.SetGroupItemWeight;
using TeleWave.Application.Programming.Groups.Suggest;
using TeleWave.Application.Programming.Groups.UpdateGroup;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class GroupEndpoints
{
public static IEndpointRouteBuilder MapGroupEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/groups")
.WithTags("Admin.Groups")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListGroups).Produces<IReadOnlyList<GroupSummaryDto>>();
admin.MapGet("/{id:guid}", GetGroup).Produces<GroupDto>();
admin.MapPost("", CreateGroup).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{id:guid}", UpdateGroup).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteGroup).Produces(StatusCodes.Status204NoContent);
// Подбор по правилу набора: правило можно передать в теле, чтобы крутить его до сохранения.
admin
.MapPost("/{id:guid}/candidates", FindCandidates)
.Produces<IReadOnlyList<GroupCandidateDto>>();
// Предложения: что имеет смысл завести группой при нынешней библиотеке.
admin.MapGet("/suggestions", Suggestions).Produces<IReadOnlyList<GroupSuggestionDto>>();
admin
.MapPost("/suggestions", CreateFromSuggestion)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
admin
.MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/items/{itemId:guid}/weight", SetWeight)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
// Поправки к вычисленному составу динамической группы.
admin.MapPost("/{id:guid}/exclusions", Exclude).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListGroups(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListGroupsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetGroup(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetGroupQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateGroup(
CreateGroupCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/groups/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> Suggestions(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new SuggestGroupsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> CreateFromSuggestion(
CreateGroupFromSuggestionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateGroupFromSuggestionCommand(body.Key),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/groups/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateGroup(
Guid id,
UpdateGroupBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter, body.Mode),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteGroup(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteGroupCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> FindCandidates(
Guid id,
FindCandidatesBody? body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new FindGroupCandidatesQuery(id, body?.Filter),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddElements(
Guid id,
AddGroupElementsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddGroupElementsCommand(id, body.Elements),
cancellationToken
);
return result.IsSuccess
? Results.Ok(new AddedCountResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveItem(
Guid id,
Guid itemId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveGroupItemCommand(id, itemId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetWeight(
Guid id,
Guid itemId,
GroupItemWeightBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetGroupItemWeightCommand(id, itemId, body.Weight),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Exclude(
Guid id,
ExcludeElementBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ExcludeGroupElementCommand(id, body.ElementKind, body.ElementId, body.Excluded),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Reorder(
Guid id,
ReorderGroupBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ReorderGroupCommand(id, body.ItemIdsInOrder),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateGroupBody(
string Name,
string? Description,
GroupFilter? Filter,
GroupMode? Mode = null
);
/// <summary>Исключить элемент из динамической группы либо вернуть его правилу.</summary>
public sealed record ExcludeElementBody(
GroupElementKind ElementKind,
Guid ElementId,
bool Excluded
);
public sealed record FindCandidatesBody(GroupFilter? Filter);
public sealed record CreateGroupFromSuggestionBody(string Key);
public sealed record AddGroupElementsBody(IReadOnlyList<GroupElementRef> Elements);
public sealed record GroupItemWeightBody(int Weight);
public sealed record ReorderGroupBody(IReadOnlyList<Guid> ItemIdsInOrder);
/// <summary>Сколько позиций реально добавлено (уже входящие в группу пропускаются).</summary>
public sealed record AddedCountResponse(int Added);
@@ -1,131 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Images;
using TeleWave.Application.Images.DeleteImage;
using TeleWave.Application.Images.GetImageFile;
using TeleWave.Application.Images.ListImages;
using TeleWave.Application.Images.UploadImage;
using TeleWave.Domain.Images;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ImageEndpoints
{
private const long MaxBytes = 50L * 1024 * 1024; // 50 МБ
// Тип поля — HashSet, а не IReadOnlySet: поле приватное, и через интерфейс Contains уходит
// в виртуальный вызов вместо прямого.
private static readonly HashSet<string> AllowedExtensions = new(
StringComparer.OrdinalIgnoreCase
)
{
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".gif",
};
public static IEndpointRouteBuilder MapImageEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/images")
.WithTags("Admin.Images")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", ListImages).Produces<IReadOnlyList<ImageDto>>();
admin.MapPost("", UploadImage).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapDelete("/{id:guid}", DeleteImage).Produces(StatusCodes.Status204NoContent);
// Публичная отдача файла (для <img> у зрителя и в админке).
app.MapGet("/api/images/{id:guid}", ServeImage).WithTags("Images");
return app;
}
private static async Task<IResult> ListImages(
ImageCategory category,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListImagesQuery(category), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UploadImage(
ImageCategory category,
string fileName,
HttpRequest request,
IImageStore storage,
ISender sender,
CancellationToken cancellationToken
)
{
if (request.ContentLength is > MaxBytes or 0 or null)
return ImageErrors.InvalidFile.ToProblem();
var ext = Path.GetExtension(fileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return ImageErrors.InvalidFile.ToProblem();
var created = await sender.Send(
new UploadImageCommand(category, ext, fileName),
cancellationToken
);
if (!created.IsSuccess)
return created.ToHttpResult();
try
{
await storage.SaveAsync(created.Value, ext, request.Body, cancellationToken);
}
catch
{
// Файл не сохранился — не оставляем висячую запись реестра.
await sender.Send(new DeleteImageCommand(created.Value), cancellationToken);
throw;
}
return Results.Created(
$"/api/images/{created.Value}",
new CreatedIdResponse(created.Value)
);
}
private static async Task<IResult> DeleteImage(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteImageCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ServeImage(
Guid id,
HttpResponse response,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetImageFileQuery(id), cancellationToken);
if (!result.IsSuccess)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=86400";
return Results.File(result.Value, ContentTypeFor(Path.GetExtension(result.Value)));
}
private static string ContentTypeFor(string extension) =>
extension.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
".bmp" => "image/bmp",
_ => "image/jpeg",
};
}
@@ -1,64 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library.Interstitials;
using TeleWave.Application.Library.Interstitials.ImportInterstitials;
using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
using TeleWave.Application.Library.Interstitials.ListInterstitials;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Ролики-врезки: тот же <c>Show(Kind = Interstitial)</c>, но со своим экраном (см. 6.7). Правка
/// названия и удаление идут через обычные эндпоинты шоу — здесь только то, чего у библиотеки нет:
/// список с длительностями, блоки и импорт загруженных файлов.
/// </summary>
public static class InterstitialEndpoints
{
public static IEndpointRouteBuilder MapInterstitialEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/interstitials")
.WithTags("Admin.Interstitials")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", List).Produces<IReadOnlyList<InterstitialDto>>();
admin.MapGet("/blocks", ListBlocks).Produces<IReadOnlyList<InterstitialBlockDto>>();
admin.MapPost("/import", Import).Produces<ImportInterstitialsResponse>();
return app;
}
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListInterstitialsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ListBlocks(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListInterstitialBlocksQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Import(
ImportInterstitialsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportInterstitialsCommand(body.MediaAssetIds),
cancellationToken
);
return result.IsSuccess
? Results.Ok(new ImportInterstitialsResponse(result.Value))
: result.ToHttpResult();
}
}
public sealed record ImportInterstitialsBody(IReadOnlyList<Guid> MediaAssetIds);
public sealed record ImportInterstitialsResponse(int Imported);
@@ -1,241 +0,0 @@
using System.Globalization;
using System.Text;
using System.Xml;
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming.GetIptvGuide;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Выгрузка для внешних IPTV-плееров: M3U со списком каналов и XMLTV с программой передач.
///
/// Плеер не умеет ни Bearer, ни httpOnly-cookie — он умеет только открыть URL. Поэтому пропуск
/// зашит в сами ссылки файла долгим stream-токеном: **файл и есть доступ к эфиру**, и делиться им
/// нельзя. Срок — <c>Storage:IptvTokenDays</c>; блокировка зрителя обрывает доступ на ближайшей
/// перезагрузке плейлиста канала, а смена <c>Jwt:SigningKey</c> обнуляет все выданные разом.
/// </summary>
public static class IptvEndpoints
{
/// <summary>Сколько суток программы класть в XMLTV: горизонт ленты и так неделя.</summary>
private static readonly TimeSpan GuideWindow = TimeSpan.FromDays(7);
/// <summary>Заглушка иконки канала, когда своя не задана: фавикон сайта из wwwroot.</summary>
private const string FaviconPath = "/favicon.svg";
public static IEndpointRouteBuilder MapIptvEndpoints(this IEndpointRouteBuilder app)
{
var iptv = app.MapGroup("/api/iptv").WithTags("IPTV");
// Файл забирает браузер под обычной авторизацией — токен внутрь кладёт уже сервер.
iptv.MapGet("/playlist.m3u", Playlist).RequireAuthorization();
// XMLTV тянет сам плеер, у него из пропусков только токен из ссылки.
iptv.MapGet("/epg.xml", Guide);
return app;
}
private static async Task<IResult> Playlist(
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
ISender sender,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
// Окно нулевое: плейлисту нужен только список каналов, программу плеер возьмёт из XMLTV.
var now = DateTimeOffset.UtcNow;
var channels = await sender.Send(new GetIptvGuideQuery(now, now), cancellationToken);
var (token, _) = tokens.IssueForIptv(userId);
var origin = Origin(request);
var body = RenderPlaylist(channels, origin, token);
return Results.File(
Encoding.UTF8.GetBytes(body),
"audio/x-mpegurl",
fileDownloadName: "telewave.m3u"
);
}
private static async Task<IResult> Guide(
HttpRequest request,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
if (tokens.Validate(request.Query["token"]) is not { } userId)
return Results.Unauthorized();
// Та же сверка, что и у плейлиста канала: заблокированный зритель не должен продолжать
// тянуть гид по выданному раньше файлу.
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var now = DateTimeOffset.UtcNow;
var channels = await sender.Send(
new GetIptvGuideQuery(now, now.Add(GuideWindow)),
cancellationToken
);
return Results.File(
RenderGuide(channels, Origin(request)),
"application/xml; charset=utf-8",
fileDownloadName: "telewave-epg.xml"
);
}
/// <summary>
/// Абсолютный адрес сервиса. Плееру нужны именно абсолютные ссылки — относительные ему не от
/// чего считать. Схема и хост берутся из запроса: TLS внешний, и их подставляет
/// <c>ForwardedHeaders</c>.
/// </summary>
private static string Origin(HttpRequest request) => $"{request.Scheme}://{request.Host}";
private static string RenderPlaylist(
IReadOnlyList<IptvChannelDto> channels,
string origin,
string token
)
{
var sb = new StringBuilder();
sb.Append(
CultureInfo.InvariantCulture,
$"#EXTM3U x-tvg-url=\"{origin}/api/iptv/epg.xml?token={token}\"\n"
);
foreach (var channel in channels)
{
// tvg-logo ставим всегда: своя иконка либо фавикон-заглушка — иначе в плеере пустой квадрат.
var logo = $" tvg-logo=\"{ChannelIcon(origin, channel.IconImageId)}\"";
var number = channel.Number is { } value
? $" tvg-chno=\"{value.ToString(CultureInfo.InvariantCulture)}\""
: string.Empty;
var name = Attribute(channel.Name);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXTINF:-1 tvg-id=\"{Attribute(channel.Slug)}\" tvg-name=\"{name}\"{number}{logo} group-title=\"TeleWave\",{name}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"{origin}/api/channels/{channel.Slug}/live.m3u8?token={token}\n"
);
}
return sb.ToString();
}
/// <summary>
/// Значение атрибута M3U. Экранирования в формате нет вовсе, поэтому кавычки и переводы строк
/// не заменяются, а вырезаются: иначе название канала рвало бы строку файла.
/// </summary>
private static string Attribute(string value) =>
value.Replace("\"", string.Empty).ReplaceLineEndings(" ").Trim();
private static byte[] RenderGuide(IReadOnlyList<IptvChannelDto> channels, string origin)
{
using var output = new MemoryStream();
// Пишем сразу в поток в UTF-8, а не в StringBuilder: XmlWriter поверх строки объявил бы
// encoding="utf-16" (строки .NET — UTF-16), тогда как байты мы всё равно отдаём в UTF-8.
// Часть плееров (Go encoding/xml) отвергает такое расхождение декларации и реальных байт.
// BOM не пишем: XMLTV его не ждёт, и он мешает строгим парсерам.
// Через XmlWriter, а не склейкой строк: в названия и описания приходит текст из библиотеки,
// и экранировать его руками — верный способ однажды отдать битый XML.
using (
var writer = XmlWriter.Create(
output,
new XmlWriterSettings
{
Indent = true,
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
}
)
)
{
writer.WriteStartDocument();
writer.WriteStartElement("tv");
writer.WriteAttributeString("generator-info-name", "TeleWave");
// Сначала все каналы, потом все передачи: XMLTV требует именно такого порядка секций.
foreach (var channel in channels)
WriteChannel(writer, channel, origin);
foreach (var channel in channels)
{
foreach (var programme in channel.Programmes)
WriteProgramme(writer, channel.Slug, programme, origin);
}
writer.WriteEndElement();
writer.WriteEndDocument();
}
return output.ToArray();
}
private static void WriteChannel(XmlWriter writer, IptvChannelDto channel, string origin)
{
writer.WriteStartElement("channel");
writer.WriteAttributeString("id", channel.Slug);
writer.WriteElementString("display-name", channel.Name);
// Номер вторым display-name: так плееры с нумерацией подхватывают его без своих настроек.
if (channel.Number is { } number)
writer.WriteElementString(
"display-name",
number.ToString(CultureInfo.InvariantCulture)
);
// Иконку канала пишем всегда: своя либо фавикон-заглушка (в отличие от иконок передач ниже).
writer.WriteStartElement("icon");
writer.WriteAttributeString("src", ChannelIcon(origin, channel.IconImageId));
writer.WriteEndElement();
writer.WriteEndElement();
}
private static void WriteProgramme(
XmlWriter writer,
string slug,
IptvProgrammeDto programme,
string origin
)
{
writer.WriteStartElement("programme");
writer.WriteAttributeString("start", XmltvTime(programme.StartsAtUtc));
writer.WriteAttributeString("stop", XmltvTime(programme.EndsAtUtc));
writer.WriteAttributeString("channel", slug);
writer.WriteElementString("title", programme.Title);
if (!string.IsNullOrWhiteSpace(programme.SubTitle))
writer.WriteElementString("sub-title", programme.SubTitle);
if (!string.IsNullOrWhiteSpace(programme.Description))
writer.WriteElementString("desc", programme.Description);
WriteIcon(writer, programme.ImageId, origin);
writer.WriteEndElement();
}
private static void WriteIcon(XmlWriter writer, Guid? imageId, string origin)
{
if (imageId is not { } id)
return;
writer.WriteStartElement("icon");
writer.WriteAttributeString("src", $"{origin}/api/images/{id}");
writer.WriteEndElement();
}
/// <summary>URL иконки канала: своя из реестра либо фавикон-заглушка сайта (абсолютный адрес).</summary>
private static string ChannelIcon(string origin, Guid? iconImageId) =>
iconImageId is { } id ? $"{origin}/api/images/{id}" : $"{origin}{FaviconPath}";
/// <summary>Время XMLTV: <c>YYYYMMDDHHMMSS +0000</c>. Отдаём в UTC — плеер сдвинет сам.</summary>
private static string XmltvTime(DateTimeOffset moment) =>
moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)
+ " +0000";
}
@@ -1,156 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Templates.Junctions;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Шаблоны стыков: что играет между программами. Стыки общие для всех каналов, канал только
/// ссылается на них слотами. Как и правка сетки, эфира не двигают — помечают шаблоны каналов,
/// которые их используют, изменёнными, а хвост пересобирается применением.
/// </summary>
public static class JunctionEndpoints
{
public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/junctions")
.WithTags("Admin.Junctions")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", List).Produces<IReadOnlyList<JunctionTemplateDto>>();
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/{junctionId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{junctionId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{junctionId:guid}/elements", AddElement)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{junctionId:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListJunctionsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Create(
JunctionNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateJunctionCommand(body.Name), cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/junctions/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> Update(
Guid junctionId,
UpdateJunctionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateJunctionCommand(junctionId, body.Name, body.MaxTotalSeconds),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Delete(
Guid junctionId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteJunctionCommand(junctionId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddElement(
Guid junctionId,
JunctionElementKindBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddJunctionElementCommand(junctionId, body.Kind),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/junctions/{junctionId}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateElement(
Guid junctionId,
Guid elementId,
JunctionElementInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateJunctionElementCommand(junctionId, elementId, input),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveElement(
Guid junctionId,
Guid elementId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveJunctionElementCommand(junctionId, elementId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Reorder(
Guid junctionId,
ReorderJunctionBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ReorderJunctionCommand(junctionId, body.Order),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record JunctionNameBody(string Name);
public sealed record UpdateJunctionBody(string Name, int? MaxTotalSeconds);
public sealed record JunctionElementKindBody(JunctionElementKind Kind);
/// <summary>Порядок врезок вместе с их развилками — перетаскивание меняет и то, и другое разом.</summary>
public sealed record ReorderJunctionBody(IReadOnlyList<JunctionElementOrder> Order);
@@ -1,52 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Maintenance.ClearAllMedia;
using TeleWave.Application.Maintenance.DeleteAllShows;
using TeleWave.Application.Maintenance.DeleteShowMedia;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class MaintenanceEndpoints
{
public static IEndpointRouteBuilder MapMaintenanceEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/maintenance")
.WithTags("Admin.Maintenance")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("/clear-media", ClearMedia).Produces<int>();
admin.MapPost("/clear-shows", ClearShows).Produces<int>();
admin.MapPost("/shows/{showId:guid}/clear-media", ClearShowMedia).Produces<int>();
return app;
}
private static async Task<IResult> ClearMedia(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearAllMediaCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearShows(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteAllShowsCommand(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearShowMedia(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowMediaCommand(showId), cancellationToken);
return result.ToHttpResult();
}
}
@@ -1,306 +0,0 @@
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Options;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.MovieImport;
using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Retry;
using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints
{
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/media")
.WithTags("Admin.Media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Перезапуск упавшей обработки: файл на месте, повторить нарезку — обычное дело.
admin.MapPost("/{id:guid}/retry", Retry).Produces<RetryMediaResultDto>();
admin.MapPost("/retry-failed", RetryFailed).Produces<RetryMediaResultDto>();
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
// Разбор фильмов: имена приходят с клиента (каталог manual/ либо выбранные в браузере
// файлы), сервер разбирает их и ищет в источнике. Импорт заводит шоу на файл.
admin.MapPost("/movies/match", MatchMovies).Produces<IReadOnlyList<MovieMatchDto>>();
admin.MapPost("/movies/match-one", MatchMovie).Produces<MovieMatchDto>();
admin.MapPost("/movies/import", ImportMovies).Produces<ImportMoviesResultDto>();
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
return app;
}
private static async Task<IResult> Retry(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RetryMediaCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RetryFailed(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RetryMediaCommand(), cancellationToken);
return result.ToHttpResult();
}
/// <summary>
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
/// </summary>
private static async Task<IResult> Upload(
string fileName,
HttpRequest request,
IMediaStorage storage,
IMediaProcessingQueue queue,
ISender sender,
UploadLimits limits,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(fileName))
return Results.Problem(
title: MediaErrors.EmptyFileName.Code,
detail: MediaErrors.EmptyFileName.Message,
statusCode: StatusCodes.Status400BadRequest
);
if (!MediaFormats.IsAllowed(fileName))
return Results.Problem(
title: MediaErrors.UnsupportedFormat.Code,
detail: MediaErrors.UnsupportedFormat.Message,
statusCode: StatusCodes.Status400BadRequest
);
var contentLength = request.ContentLength ?? 0;
if (contentLength > limits.MaxUploadBytes)
return Results.Problem(
title: MediaErrors.FileTooLarge.Code,
detail: MediaErrors.FileTooLarge.Message,
statusCode: StatusCodes.Status400BadRequest
);
var free = storage.GetAvailableFreeSpaceBytes();
if (free - contentLength < limits.MinFreeSpaceBytes)
return Results.Problem(
title: MediaErrors.InsufficientStorage.Code,
detail: MediaErrors.InsufficientStorage.Message,
statusCode: StatusCodes.Status409Conflict
);
var extension = Path.GetExtension(fileName);
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
// Файл уже на диске в uploads/, но в БД его ещё нет и ссылок на токен нигде не остаётся:
// любой исход, кроме успешной регистрации, обязан за собой убрать. Удаляем и при исключении
// (оборванный запрос, сбой БД) — иначе гигабайты остаются в uploads/ до уборщика.
// Токен отмены для уборки не пробрасываем: при обрыве запроса он уже сработал.
Result<Guid> result;
try
{
result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken
);
}
catch
{
await storage.DeleteUploadAsync(token, CancellationToken.None);
throw;
}
if (!result.IsSuccess)
{
await storage.DeleteUploadAsync(token, CancellationToken.None);
return result.ToHttpResult();
}
queue.Enqueue(result.Value);
return Results.Created(
$"/api/admin/media/{result.Value}",
new UploadMediaResponse(result.Value)
);
}
private static async Task<IResult> List(
[AsParameters] ListMediaFilter filter,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListMediaAssetsQuery(
filter.Page is > 0 ? filter.Page.Value : 1,
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
filter.Status ?? [],
filter.Search,
filter.Sort,
filter.Desc ?? false
),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Delete(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListManual(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> MatchMovies(
MatchMoviesBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new MatchMoviesQuery(body.Names, body.Provider),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> MatchMovie(
MatchMovieBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new MatchMovieQuery(body.Name, body.Title, body.Year, body.Provider),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> ImportMovies(
ImportMoviesBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportMoviesCommand(body.Items, body.Provider),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ImportManual(
ImportManualInboxBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
{
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
return Results.NotFound();
var baseUrl = $"/api/admin/media/{id}/preview/";
var sb = new StringBuilder();
foreach (var line in File.ReadLines(indexPath))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
continue;
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
.Append('\n');
}
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
}
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
{
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
}
/// <summary>
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
/// </summary>
public sealed record ListMediaFilter(
int? Page,
int? PageSize,
MediaAssetStatus[]? Status,
string? Search,
string? Sort,
bool? Desc
);
public sealed record UploadMediaResponse(Guid Id);
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
public sealed record MatchMoviesBody(IReadOnlyList<string> Names, string Provider);
public sealed record MatchMovieBody(string Name, string Title, int? Year, string Provider);
public sealed record ImportMoviesBody(IReadOnlyList<MovieImportItem> Items, string Provider);
@@ -1,160 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Metadata;
using TeleWave.Application.Metadata.ApplyShowMetadata;
using TeleWave.Application.Metadata.ClearShowMetadata;
using TeleWave.Application.Metadata.FindMissingEpisodes;
using TeleWave.Application.Metadata.FindMissingSeasons;
using TeleWave.Application.Metadata.GetProviders;
using TeleWave.Application.Metadata.RefreshEpisodes;
using TeleWave.Application.Metadata.SearchShows;
using TeleWave.Application.Metadata.SetShowPoster;
using TeleWave.Application.Metadata.UpdateShowMetadata;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class MetadataEndpoints
{
public static IEndpointRouteBuilder MapMetadataEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/metadata")
.WithTags("Admin.Metadata")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/providers", GetProviders).Produces<IReadOnlyList<string>>();
admin.MapGet("/search", Search).Produces<IReadOnlyList<MetadataCandidate>>();
admin.MapPost("/shows/{showId:guid}/apply", Apply).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/shows/{showId:guid}", Update).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/shows/{showId:guid}", Clear).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/shows/{showId:guid}/poster-image", SetPosterImage)
.Produces(StatusCodes.Status204NoContent);
admin.MapPost("/shows/{showId:guid}/refresh-episodes", RefreshEpisodes).Produces<int>();
admin
.MapGet("/shows/{showId:guid}/missing-episodes", FindMissing)
.Produces<MissingEpisodesReport>();
admin
.MapGet("/shows/{showId:guid}/missing-seasons", FindMissingSeasons)
.Produces<MissingSeasonsReport>();
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}.
return app;
}
private static async Task<IResult> GetProviders(
ISender sender,
CancellationToken cancellationToken
)
{
var providers = await sender.Send(new GetMetadataProvidersQuery(), cancellationToken);
return Results.Ok(providers);
}
private static async Task<IResult> Search(
string provider,
string query,
ShowKind kind,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SearchShowMetadataQuery(provider, query, kind),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Apply(
Guid showId,
ApplyMetadataBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyShowMetadataCommand(showId, body.Provider, body.ExternalId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Update(
Guid showId,
UpdateMetadataBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateShowMetadataCommand(showId, body.Description, body.Year),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> Clear(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ClearShowMetadataCommand(showId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetPosterImage(
Guid showId,
SetPosterImageBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowPosterCommand(showId, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RefreshEpisodes(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RefreshShowEpisodesMetadataCommand(showId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> FindMissing(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new FindMissingEpisodesQuery(showId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> FindMissingSeasons(
Guid showId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new FindMissingSeasonsQuery(showId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
public sealed record UpdateMetadataBody(string? Description, int? Year);
public sealed record SetPosterImageBody(Guid? ImageId);
@@ -1,89 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Admin.Roles.ChangeUserRole;
using TeleWave.Application.Admin.Roles.CreateRole;
using TeleWave.Application.Admin.Roles.DeleteRole;
using TeleWave.Application.Admin.Roles.ListRoles;
using TeleWave.Application.Admin.Roles.UpdateRole;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class RoleEndpoints
{
public static IEndpointRouteBuilder MapRoleEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Roles")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/roles", ListRoles).Produces<IReadOnlyList<RoleDto>>();
admin.MapPost("/roles", CreateRole).Produces<RoleDto>();
admin.MapPut("/roles/{id:guid}", UpdateRole).Produces<RoleDto>();
admin.MapDelete("/roles/{id:guid}", DeleteRole).Produces(StatusCodes.Status204NoContent);
admin
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> ListRoles(
ISender sender,
CancellationToken cancellationToken
)
{
var roles = await sender.Send(new ListRolesQuery(), cancellationToken);
return Results.Ok(roles);
}
private static async Task<IResult> CreateRole(
CreateRoleCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateRole(
Guid id,
UpdateRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateRoleCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteRole(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteRoleCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ChangeUserRole(
Guid id,
ChangeUserRoleBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ChangeUserRoleCommand(id, body.RoleId),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateRoleBody(string Name);
public sealed record ChangeUserRoleBody(Guid RoleId);
@@ -1,55 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Settings;
using TeleWave.Application.Settings.GetSiteSettings;
using TeleWave.Application.Settings.UpdateSiteSettings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/settings")
.WithTags("Admin.Settings")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings);
}
private static async Task<IResult> UpdateSettings(
UpdateSiteSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateSiteSettingsCommand(
body.RegistrationEnabled,
body.PreferredAudioLanguages ?? "",
body.ChannelNumbersEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled,
string? PreferredAudioLanguages,
bool ChannelNumbersEnabled
);
@@ -1,259 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.BulkTagShows;
using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.GetShowUsage;
using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowAudience;
using TeleWave.Application.Library.SetShowGenres;
using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Application.Metadata.EnrichShows;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ShowEndpoints
{
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/shows")
.WithTags("Admin.Shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
admin.MapGet("/{id:guid}/usage", GetUsage).Produces<ShowUsageDto>();
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
// Массовые операции над отмеченными шоу: разметка руками и обогащение из источника.
admin.MapPost("/bulk/tag", BulkTag).Produces<BulkTaggedResponse>();
admin.MapPost("/bulk/enrich", BulkEnrich).Produces<EnrichShowsResultDto>();
admin
.MapPost("/{id:guid}/episodes", AddEpisode)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> CreateShow(
CreateShowCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/shows/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListShows(
ISender sender,
CancellationToken cancellationToken,
Guid? genreId = null,
bool interstitials = false
)
{
var result = await sender.Send(
new ListShowsQuery(genreId, interstitials),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> GetShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Rename(
Guid id,
RenameShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetOriginalName(
Guid id,
SetShowOriginalNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowOriginalNameCommand(id, body.OriginalName),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetAudience(
Guid id,
SetShowAudienceBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowAudienceCommand(id, body.Audience),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetGenres(
Guid id,
SetShowGenresBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
cancellationToken
);
return result.ToHttpResult();
}
/// <param name="withMedia">Снести заодно файлы шоу — иначе они останутся осиротевшими.</param>
/// <param name="withSchedule">Вырезать шоу из будущего эфира вместо отказа.</param>
private static async Task<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken,
bool withMedia = false,
bool withSchedule = false
)
{
var result = await sender.Send(
new DeleteShowCommand(id, withMedia, withSchedule),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddEpisode(
Guid id,
AddEpisodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveEpisode(
Guid id,
Guid episodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetUsage(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowUsageQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> BulkTag(
BulkTagShowsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new BulkTagShowsCommand(
body.ShowIds,
body.GenreIds,
body.ReplaceGenres,
body.Audience,
body.SetAudience
),
cancellationToken
);
return result.IsSuccess
? Results.Ok(new BulkTaggedResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> BulkEnrich(
BulkEnrichBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new EnrichShowsMetadataCommand(body.ShowIds, body.Provider),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record AddEpisodeBody(Guid MediaAssetId);
/// <summary>Массовая разметка: жанры и рейтинг применяются независимо друг от друга.</summary>
public sealed record BulkTagShowsBody(
IReadOnlyList<Guid> ShowIds,
IReadOnlyList<Guid>? GenreIds = null,
bool ReplaceGenres = false,
ShowAudience? Audience = null,
bool SetAudience = false
);
public sealed record BulkEnrichBody(IReadOnlyList<Guid> ShowIds, string Provider);
/// <summary>Скольких шоу коснулась массовая правка.</summary>
public sealed record BulkTaggedResponse(int Updated);
public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName);
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
public sealed record SetShowAudienceBody(ShowAudience? Audience);
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
@@ -1,33 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Storage;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>Чем занят диск: объём по областям хранилища и заполненность тома.</summary>
public static class StorageEndpoints
{
public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/admin/storage", Stats)
.WithTags("Admin.Storage")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
.Produces<StorageStatsDto>();
return app;
}
/// <param name="refresh">Пересчитать, не дожидаясь истечения кэша: обход дерева долгий.</param>
private static async Task<IResult> Stats(
bool? refresh,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new GetStorageStatsQuery(refresh ?? false),
cancellationToken
);
return Results.Ok(result);
}
}
@@ -1,214 +0,0 @@
using System.Globalization;
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
private const string StreamCookieName = "tw_stream";
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app;
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ViewerFeatures(
ISiteSettings siteSettings,
CancellationToken cancellationToken
) =>
Results.Ok(
new ViewerFeaturesDto(
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
)
);
/// <summary>
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
/// </summary>
private static IResult Watch(
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response,
IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append(
StreamCookieName,
token,
new CookieOptions
{
HttpOnly = true,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
}
);
return Results.NoContent();
}
private static async Task<IResult> Epg(
string slug,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> LivePlaylist(
string slug,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
// Для IPTV это единственная точка, где блокировка вообще может сработать: у внешнего плеера
// пропуск долгий и перевыпуску не подлежит.
if (Viewer(request, tokens) 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(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
// Токен из ссылки тянется дальше в адреса сегментов: плеер разрешает их относительно
// плейлиста и query при этом теряет, а cookie у него нет — без этого сегменты дадут 401.
var token = request.Query["token"].ToString();
var suffix = string.IsNullOrEmpty(token)
? string.Empty
: $"?token={Uri.EscapeDataString(token)}";
response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value, suffix), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (Viewer(request, tokens) is null)
return Results.Unauthorized();
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
/// <summary>
/// Кто смотрит: cookie — для браузера, токен в ссылке — для внешнего IPTV-плеера, который
/// cookie не носит. Токен один и тот же по формату, различается только сроком выдачи.
/// </summary>
private static Guid? Viewer(HttpRequest request, StreamTokenService tokens) =>
tokens.Validate(request.Cookies[StreamCookieName])
?? tokens.Validate(request.Query["token"]);
private static string Render(LivePlaylistDto playlist, string segmentSuffix)
{
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder();
sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n");
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
);
// Без DISCONTINUITY-SEQUENCE ffmpeg-плееры (Jellyfin) теряют счёт склеек, когда тег
// EXT-X-DISCONTINUITY уезжает за верхний край окна, и встают на стыке серий.
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-DISCONTINUITY-SEQUENCE:{playlist.DiscontinuitySequence}\n"
);
foreach (var segment in playlist.Segments)
{
if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append(
CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts{segmentSuffix}\n"
);
}
return sb.ToString();
}
}
/// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
@@ -1,175 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Notifications;
using TeleWave.Application.Notifications.Link;
using TeleWave.Application.Notifications.Settings;
using TeleWave.Application.Notifications.Subscribers;
using TeleWave.Domain.Notifications;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Бот расписания: настройка в админке, привязка чата зрителем и приём обновлений вебхуком.
/// </summary>
public static class TelegramEndpoints
{
/// <summary>Заголовок, которым Telegram подтверждает, что вызов вебхука — его.</summary>
private const string SecretHeader = "X-Telegram-Bot-Api-Secret-Token";
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/telegram")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("/settings", GetSettings).Produces<TelegramSettingsDto>();
admin.MapPut("/settings", UpdateSettings).Produces<TelegramSettingsDto>();
admin
.MapGet("/subscribers", ListSubscribers)
.Produces<IReadOnlyList<TelegramSubscriberDto>>();
// Настроен ли бот — публично: по этому флагу зрительская страница решает, показывать ли
// кнопку. Ничего, кроме флага и имени бота, здесь не отдаётся.
app.MapGet("/api/telegram/status", Status).AllowAnonymous().Produces<TelegramStatusDto>();
// Ссылка на чат — зрителю, а не админу: код привязывает его собственную учётку.
app.MapPost("/api/telegram/link", IssueLink)
.RequireAuthorization()
.Produces<TelegramLinkDto>();
// Вебхук открыт наружу по построению: его вызывает Telegram, а не наш фронт. Пускаем
// только по секрету из настроек — адрес рано или поздно окажется в чужих логах.
app.MapPost("/api/telegram/webhook", Webhook).AllowAnonymous();
return app;
}
private static async Task<IResult> Status(
IAppDbContext dbContext,
CancellationToken cancellationToken
)
{
var settings = await dbContext
.TelegramSettings.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);
var link = settings is null ? null : TelegramSettingsService.BotLink(settings);
return Results.Ok(new TelegramStatusDto(link is not null, settings?.BotUsername));
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetTelegramSettingsQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
TelegramSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTelegramSettingsCommand(
body.IsEnabled,
body.Transport,
body.BotToken,
body.WebhookUrl,
body.ProxyKind,
body.ProxyHost,
body.ProxyPort,
body.ProxyUsername,
body.ProxyPassword
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ListSubscribers(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListTelegramSubscribersQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> IssueLink(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new IssueTelegramLinkCommand(), cancellationToken);
return result.ToHttpResult();
}
/// <summary>
/// Приём обновления от Telegram. Разбираем тем же сервисом, что и опрос: транспорт разный,
/// разговор один.
///
/// Отвечаем 200 всегда, когда секрет сошёлся: на ошибку Telegram повторяет доставку, и одно
/// кривое обновление зациклило бы вебхук.
/// </summary>
private static async Task<IResult> Webhook(
HttpRequest request,
IAppDbContext dbContext,
TelegramBotService bot,
ITelegramApi api,
ILoggerFactory loggers,
CancellationToken cancellationToken
)
{
var settings = await dbContext.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
if (
settings is null
|| !settings.IsOperational
|| settings.Transport != TelegramTransport.Webhook
)
return Results.NotFound();
var secret = request.Headers[SecretHeader].ToString();
if (string.IsNullOrWhiteSpace(settings.WebhookSecret) || secret != settings.WebhookSecret)
return Results.Unauthorized();
var update = await api.ReadUpdateAsync(request.Body, cancellationToken);
if (update is null)
return Results.Ok();
var logger = loggers.CreateLogger("Telegram.Webhook");
try
{
await bot.HandleAsync(settings, update, DateTimeOffset.UtcNow, cancellationToken);
settings.MarkContact(DateTimeOffset.UtcNow);
settings.AdvanceUpdateId(update.UpdateId);
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
logger.LogError(exception, "Не разобрано обновление {UpdateId}", update.UpdateId);
}
return Results.Ok();
}
}
/// <summary>Что зрительской части нужно знать о боте: настроен ли и как он называется.</summary>
public sealed record TelegramStatusDto(bool IsConfigured, string? BotUsername);
/// <summary>Тело правки настроек: секреты необязательны — пусто значит «не менять».</summary>
public sealed record TelegramSettingsBody(
bool IsEnabled,
TelegramTransport Transport,
string? BotToken,
string? WebhookUrl,
TelegramProxyKind ProxyKind,
string? ProxyHost,
int? ProxyPort,
string? ProxyUsername,
string? ProxyPassword
);
@@ -1,434 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Application.Programming.Planning.Diff;
using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.Reset;
using TeleWave.Application.Programming.Templates.Restore;
using TeleWave.Application.Programming.Templates.Transfer;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
/// изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public static class TemplateEndpoints
{
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Templates")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
.Produces<ScheduleTemplateDto>();
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
admin
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
.Produces(StatusCodes.Status204NoContent);
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>();
// Откат правок к состоянию, по которому идёт эфир. Ленту не трогает — она и так по нему.
admin
.MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate)
.Produces<RestoreTemplateResultDto>();
// Сброс сетки вместе с собранным по ней будущим эфиром. Прошлое и идущая запись остаются.
admin
.MapPost("/channels/{channelId:guid}/template/reset", ResetTemplate)
.Produces<ResetTemplateResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
.Produces<SchedulePreviewDto>();
// Проверки по правилам — только по шаблону, без прогона генератора.
admin
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
.Produces<IReadOnlyList<TemplateIssueDto>>();
// Что изменится в эфире, если применить прямо сейчас.
admin
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
.Produces<ScheduleDiffDto>();
admin
.MapPost(
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
CopyTemplate
)
.Produces<CopyTemplateResultDto>();
// Автосборка сетки: каталог профилей, план по выбранному и его создание. План считается
// одним и тем же кодом, поэтому предпросмотр показывает ровно то, что создаст кнопка.
admin
.MapGet("/grid-profiles", ListGridProfiles)
.Produces<IReadOnlyList<GridProfileDto>>();
admin
.MapGet("/channels/{channelId:guid}/template/grid-plan", PreviewGrid)
.Produces<GridPlanDto>();
admin
.MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid)
.Produces<GenerateGridResultDto>();
// Обмен конфигурацией: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ.
// Запрос никуда не отправляется — админ копирует его в свою модель и приносит ответ назад.
admin
.MapGet("/channels/{channelId:guid}/template/export", ExportGrid)
.Produces<GridConfig>();
admin
.MapPost("/channels/{channelId:guid}/template/import", ImportGrid)
.Produces<GridImportResultDto>();
admin
.MapPost("/channels/{channelId:guid}/template/ai-prompt", BuildPrompt)
.Produces<GridPromptDto>();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/layers/{layerId:guid}", UpdateLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateChannelTemplateCommand(channelId),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{channelId}/template",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ApplyTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyChannelTemplateCommand(channelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RestoreTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RestoreTemplateCommand(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ResetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ResetTemplateCommand(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PreviewTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken,
int days = 1
)
{
var result = await sender.Send(
new PreviewScheduleQuery(channelId, days),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ValidateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DiffTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListGridProfiles(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListGridProfilesQuery(), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> PreviewGrid(
Guid channelId,
GridProfileKind profile,
GridGenerationMode mode,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new PreviewGeneratedGridQuery(channelId, profile, mode),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GenerateGrid(
Guid channelId,
GenerateGridBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new GenerateGridCommand(channelId, body.Profile, body.Mode),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ExportGrid(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ExportGridQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ImportGrid(
Guid channelId,
ImportGridBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportGridCommand(channelId, body.Config, body.Replace),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> BuildPrompt(
Guid channelId,
GridPromptBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new BuildGridPromptQuery(channelId, body.References ?? [], body.Notes),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CopyTemplate(
Guid channelId,
Guid targetChannelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CopyTemplateCommand(channelId, targetChannelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTemplate(
Guid templateId,
UpdateTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTemplateCommand(
templateId,
body.Name,
body.FallbackGroupId,
body.DefaultJunctionId,
body.Rules
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CreateLayer(
Guid templateId,
CreateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateLayerCommand(templateId, body.Name, body.Priority),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/layers/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateLayer(
Guid layerId,
UpdateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateLayerCommand(
layerId,
body.Name,
body.Priority,
body.Applicability,
body.IsEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteLayer(
Guid layerId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateSlot(
Guid layerId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/slots/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateSlot(
Guid slotId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteSlot(
Guid slotId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateTemplateBody(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
);
public sealed record GenerateGridBody(GridProfileKind Profile, GridGenerationMode Mode);
public sealed record ImportGridBody(GridConfig Config, bool Replace);
public sealed record GridPromptBody(IReadOnlyList<string>? References, string? Notes);
public sealed record CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody(
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
);
-162
View File
@@ -1,162 +0,0 @@
using System.Net;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore;
using Serilog;
using TeleWave.Api.Common;
using TeleWave.Api.Endpoints;
using TeleWave.Application;
using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
builder.WebHost.ConfigureKestrel(options =>
options.Limits.MaxRequestBodySize =
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (var proxy in ReadList(builder.Configuration, "ForwardedHeaders:KnownProxies"))
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (var network in ReadList(builder.Configuration, "ForwardedHeaders:KnownNetworks"))
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
}
// Список доверенных прокси/сетей приходит из .env одной строкой (env-переменная по одному ключу
// в string[] не биндится) — принимаем и запятые, и массив appsettings/индексы __0. Иначе
// задокументированная в .env.example запись через запятую молча игнорировалась бы, оставляя
// доверие только loopback и обрывая подхват X-Forwarded-Proto.
static string[] ReadList(IConfiguration config, string key)
{
var array = config.GetSection(key).Get<string[]>();
if (array is { Length: > 0 })
return array;
var scalar = config[key];
return string.IsNullOrWhiteSpace(scalar)
? []
: scalar.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
);
}
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<UploadLimits>();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options =>
{
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
options.AddPolicy(
RateLimiting.AuthPolicy,
httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}
)
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
// не раскрывать полную карту эндпоинтов без необходимости.
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapGenreEndpoints();
app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
app.MapBumperEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapTelegramEndpoints();
app.MapStreamingEndpoints();
app.MapIptvEndpoints();
app.MapMaintenanceEndpoints();
app.MapStorageEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
app.MapDiagnosticsEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
await app.RunAsync();
@@ -1,14 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj" />
<ProjectReference Include="..\TeleWave.Application\TeleWave.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -1,29 +0,0 @@
{
// Локальная БД разработчика. В appsettings.json строки подключения нет намеренно: этот файл
// едет в образ, и креды в нём (даже заведомо игрушечные) — это и находка сканера, и приглашение
// однажды поправить их «на месте» вместо ConnectionStrings__Default из окружения.
"ConnectionStrings": {
"Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Jwt": {
"SigningKey": "telewave-development-only-signing-key-not-for-production-0001"
},
"AdminSeed": {
"Username": "admin",
"Password": "Passw0rd!Dev"
},
"Storage": {
"RootPath": ".dev-media"
},
// Отладочные дампы каналов ложатся в папку репозитория: разбирать «почему сетка построилась
// так» приходится рядом с кодом. Путь относительный — от рабочего каталога Api (backend/src/TeleWave.Api).
"Debug": {
"ExportDirectory": "../../../debug-exports"
}
}
-69
View File
@@ -1,69 +0,0 @@
{
"Jwt": {
"Issuer": "TeleWave",
"Audience": "TeleWave",
"SigningKey": "change-me-min-32-chars-random-secret",
"AccessTokenMinutes": 15,
"RefreshTokenDays": 30
},
"AdminSeed": {
"Username": "",
"Password": ""
},
"Storage": {
"RootPath": "/media",
"SegmentSeconds": 2,
"LiveWindowSegments": 10,
"KeepOriginals": false,
"MinFreeSpaceBytes": 10737418240
},
"Scheduler": {
"HorizonDays": 7,
"RetentionDays": 90,
"TickMinutes": 30
},
"Media": {
"FfmpegPath": "ffmpeg",
"FfprobePath": "ffprobe",
"MaxUploadBytes": 21474836480,
"TranscodeThreads": 3,
"InboxScanSeconds": 15,
"NormalizeLoudness": true,
"LoudnessTargetLufs": -16
},
"Bumpers": {
"Width": 1280,
"Height": 720,
"FontFileSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"FontFileSerif": "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
"TemplateVersion": 1
},
"Metadata": {
"Language": "ru-RU",
"Tmdb": {
"ApiKey": "",
"BaseUrl": "https://api.themoviedb.org/3",
"ImageBaseUrl": "https://image.tmdb.org/t/p",
"PosterSize": "w500",
"StillSize": "w300"
},
"Omdb": {
"ApiKey": "",
"BaseUrl": "https://www.omdbapi.com"
}
},
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"WriteTo": [ { "Name": "Console" } ],
"Enrich": [ "FromLogContext" ]
},
"AllowedHosts": "*"
}
@@ -1,7 +0,0 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8" /><title>TeleWave</title></head>
<body>
<p>Frontend build not present yet — run the Vite build to populate wwwroot.</p>
</body>
</html>
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
public sealed record ChangeUserRoleCommand(Guid UserId, Guid RoleId) : ICommand<Result>;
@@ -1,14 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.ChangeUserRole;
public sealed class ChangeUserRoleCommandHandler(IRoleService roleService)
: ICommandHandler<ChangeUserRoleCommand, Result>
{
public Task<Result> Handle(
ChangeUserRoleCommand command,
CancellationToken cancellationToken
) => roleService.ChangeUserRoleAsync(command.UserId, command.RoleId, cancellationToken);
}
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed record CreateRoleCommand(string Name) : ICommand<Result<RoleDto>>;
@@ -1,14 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed class CreateRoleCommandHandler(IRoleService roleService)
: ICommandHandler<CreateRoleCommand, Result<RoleDto>>
{
public Task<Result<RoleDto>> Handle(
CreateRoleCommand command,
CancellationToken cancellationToken
) => roleService.CreateRoleAsync(command.Name, cancellationToken);
}
@@ -1,11 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Roles.CreateRole;
public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCommand>
{
public CreateRoleCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.DeleteRole;
public sealed record DeleteRoleCommand(Guid Id) : ICommand<Result>;
@@ -1,12 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.DeleteRole;
public sealed class DeleteRoleCommandHandler(IRoleService roleService)
: ICommandHandler<DeleteRoleCommand, Result>
{
public Task<Result> Handle(DeleteRoleCommand command, CancellationToken cancellationToken) =>
roleService.DeleteRoleAsync(command.Id, cancellationToken);
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Admin.Roles.ListRoles;
public sealed record ListRolesQuery : IQuery<IReadOnlyList<RoleDto>>;
@@ -1,13 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Admin.Roles.ListRoles;
public sealed class ListRolesQueryHandler(IRoleService roleService)
: IQueryHandler<ListRolesQuery, IReadOnlyList<RoleDto>>
{
public Task<IReadOnlyList<RoleDto>> Handle(
ListRolesQuery query,
CancellationToken cancellationToken
) => roleService.ListRolesAsync(cancellationToken);
}
@@ -1,28 +0,0 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles;
public static class RoleErrors
{
public static readonly Error NotFound = Error.NotFound("Roles.NotFound", "Роль не найдена.");
public static readonly Error DuplicateName = Error.Conflict(
"Roles.DuplicateName",
"Роль с таким именем уже существует."
);
public static readonly Error CannotModifySystemRole = Error.Forbidden(
"Roles.CannotModifySystemRole",
"Системную роль нельзя переименовать или удалить."
);
public static readonly Error RoleInUse = Error.Conflict(
"Roles.RoleInUse",
"Роль назначена пользователям — сначала смените им роль."
);
public static readonly Error CannotRemoveLastAdmin = Error.Conflict(
"Roles.CannotRemoveLastAdmin",
"Нельзя снять роль admin с последнего администратора."
);
}
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed record UpdateRoleCommand(Guid Id, string Name) : ICommand<Result<RoleDto>>;
@@ -1,14 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed class UpdateRoleCommandHandler(IRoleService roleService)
: ICommandHandler<UpdateRoleCommand, Result<RoleDto>>
{
public Task<Result<RoleDto>> Handle(
UpdateRoleCommand command,
CancellationToken cancellationToken
) => roleService.UpdateRoleAsync(command.Id, command.Name, cancellationToken);
}
@@ -1,11 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Roles.UpdateRole;
public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCommand>
{
public UpdateRoleCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.BlockUser;
public sealed record BlockUserCommand(Guid UserId) : ICommand<Result>;
@@ -1,19 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.BlockUser;
public sealed class BlockUserCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<BlockUserCommand, Result>
{
public Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId == command.UserId)
return Task.FromResult(Result.Failure(UserErrors.CannotBlockSelf));
return identityService.BlockUserAsync(command.UserId, cancellationToken);
}
}
@@ -1,8 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.CreateUser;
/// <summary>Создание пользователя администратором (в обход открытой регистрации), с выбором роли.</summary>
public sealed record CreateUserCommand(string UserName, string Password, Guid RoleId)
: ICommand<Result<Guid>>;
@@ -1,44 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Admin.Roles;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.CreateUser;
public sealed class CreateUserCommandHandler(
IIdentityService identityService,
IRoleService roleService
) : ICommandHandler<CreateUserCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateUserCommand command,
CancellationToken cancellationToken
)
{
// Роль проверяем до создания, чтобы не оставить пользователя с ролью по умолчанию при опечатке.
var roles = await roleService.ListRolesAsync(cancellationToken);
if (roles.All(r => r.Id != command.RoleId))
return Result.Failure<Guid>(RoleErrors.NotFound);
var createResult = await identityService.CreateUserAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!createResult.IsSuccess)
return Result.Failure<Guid>(createResult.Error);
var userId = createResult.Value;
// CreateUserAsync назначает роль по умолчанию — выставляем выбранную админом.
var roleResult = await roleService.ChangeUserRoleAsync(
userId,
command.RoleId,
cancellationToken
);
if (!roleResult.IsSuccess)
return Result.Failure<Guid>(roleResult.Error);
return Result.Success(userId);
}
}
@@ -1,13 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Users.CreateUser;
public sealed class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(64);
RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
RuleFor(x => x.RoleId).NotEmpty();
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.DeleteUser;
public sealed record DeleteUserCommand(Guid UserId) : ICommand<Result>;
@@ -1,19 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.DeleteUser;
public sealed class DeleteUserCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<DeleteUserCommand, Result>
{
public Task<Result> Handle(DeleteUserCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId == command.UserId)
return Task.FromResult(Result.Failure(UserErrors.CannotDeleteSelf));
return identityService.DeleteUserAsync(command.UserId, cancellationToken);
}
}
@@ -1,15 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ListUsers;
public sealed record ListUsersQuery(
int Page,
int PageSize,
string? Search,
Guid? RoleId,
bool? IsBlocked,
string? Sort = null,
bool Desc = false
) : IQuery<PagedList<UserSummaryDto>>;
@@ -1,26 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ListUsers;
public sealed class ListUsersQueryHandler(IIdentityService identityService)
: IQueryHandler<ListUsersQuery, PagedList<UserSummaryDto>>
{
public Task<PagedList<UserSummaryDto>> Handle(
ListUsersQuery query,
CancellationToken cancellationToken
) =>
identityService.ListUsersAsync(
new UserListFilter(
query.Page,
query.PageSize,
query.Search,
query.RoleId,
query.IsBlocked,
query.Sort,
query.Desc
),
cancellationToken
);
}
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ResetPassword;
/// <summary>Сброс пароля пользователя администратором (без текущего пароля).</summary>
public sealed record ResetUserPasswordCommand(Guid UserId, string NewPassword) : ICommand<Result>;
@@ -1,14 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.ResetPassword;
public sealed class ResetUserPasswordCommandHandler(IIdentityService identityService)
: ICommandHandler<ResetUserPasswordCommand, Result>
{
public Task<Result> Handle(
ResetUserPasswordCommand command,
CancellationToken cancellationToken
) => identityService.ResetPasswordAsync(command.UserId, command.NewPassword, cancellationToken);
}
@@ -1,13 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Admin.Users.ResetPassword;
public sealed class ResetUserPasswordCommandValidator : AbstractValidator<ResetUserPasswordCommand>
{
public ResetUserPasswordCommandValidator()
{
// Длина — здесь; сложность (цифра/заглавная) проверяют валидаторы ASP.NET Identity при сбросе.
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.UnblockUser;
public sealed record UnblockUserCommand(Guid UserId) : ICommand<Result>;
@@ -1,12 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users.UnblockUser;
public sealed class UnblockUserCommandHandler(IIdentityService identityService)
: ICommandHandler<UnblockUserCommand, Result>
{
public Task<Result> Handle(UnblockUserCommand command, CancellationToken cancellationToken) =>
identityService.UnblockUserAsync(command.UserId, cancellationToken);
}
@@ -1,21 +0,0 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Admin.Users;
public static class UserErrors
{
public static readonly Error NotFound = Error.NotFound(
"Users.NotFound",
"Пользователь не найден."
);
public static readonly Error CannotDeleteSelf = Error.Forbidden(
"Users.CannotDeleteSelf",
"Нельзя удалить собственный аккаунт через админку."
);
public static readonly Error CannotBlockSelf = Error.Forbidden(
"Users.CannotBlockSelf",
"Нельзя заблокировать собственный аккаунт."
);
}
@@ -1,36 +0,0 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth;
public static class AuthErrors
{
public static readonly Error InvalidCredentials = Error.Unauthorized(
"Auth.InvalidCredentials",
"Неверное имя пользователя или пароль."
);
public static readonly Error Unauthorized = Error.Unauthorized(
"Auth.Unauthorized",
"Требуется вход в систему."
);
public static readonly Error InvalidRefreshToken = Error.Unauthorized(
"Auth.InvalidRefreshToken",
"Сессия истекла, войдите заново."
);
public static readonly Error Blocked = Error.Forbidden(
"Auth.Blocked",
"Аккаунт заблокирован администратором."
);
public static readonly Error UserNameTaken = Error.Conflict(
"Auth.UserNameTaken",
"Это имя пользователя уже занято."
);
public static readonly Error RegistrationDisabled = Error.Forbidden(
"Auth.RegistrationDisabled",
"Регистрация на сайте отключена администратором."
);
}
@@ -1,11 +0,0 @@
namespace TeleWave.Application.Auth;
public sealed record CurrentUserDto(Guid Id, string UserName, string Role);
public sealed record AuthResult(
string AccessToken,
DateTimeOffset AccessTokenExpiresAt,
string RefreshToken,
DateTimeOffset RefreshTokenExpiresAt,
CurrentUserDto User
);
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword)
: ICommand<Result>;
@@ -1,27 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<ChangePasswordCommand, Result>
{
public async Task<Result> Handle(
ChangePasswordCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.ChangePasswordAsync(
userId,
command.CurrentPassword,
command.NewPassword,
cancellationToken
);
}
}
@@ -1,12 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Auth.ChangePassword;
public sealed class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
{
public ChangePasswordCommandValidator()
{
RuleFor(x => x.CurrentPassword).NotEmpty();
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8);
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed record ChangeUserNameCommand(string NewUserName) : ICommand<Result>;
@@ -1,26 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed class ChangeUserNameCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<ChangeUserNameCommand, Result>
{
public async Task<Result> Handle(
ChangeUserNameCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.ChangeUserNameAsync(
userId,
command.NewUserName,
cancellationToken
);
}
}
@@ -1,11 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Auth.ChangeUserName;
public sealed class ChangeUserNameCommandValidator : AbstractValidator<ChangeUserNameCommand>
{
public ChangeUserNameCommandValidator()
{
RuleFor(x => x.NewUserName).NotEmpty().MinimumLength(3).MaximumLength(64);
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.DeleteMyAccount;
public sealed record DeleteMyAccountCommand : ICommand<Result>;
@@ -1,22 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.DeleteMyAccount;
public sealed class DeleteMyAccountCommandHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : ICommandHandler<DeleteMyAccountCommand, Result>
{
public async Task<Result> Handle(
DeleteMyAccountCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
return await identityService.DeleteUserAsync(userId, cancellationToken);
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Login;
public sealed record LoginCommand(string UserName, string Password) : ICommand<Result<AuthResult>>;
@@ -1,50 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Login;
public sealed class LoginCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService
) : ICommandHandler<LoginCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
LoginCommand command,
CancellationToken cancellationToken
)
{
var credentialsResult = await identityService.ValidateCredentialsAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!credentialsResult.IsSuccess)
return Result.Failure<AuthResult>(credentialsResult.Error);
var user = credentialsResult.Value;
var profile = await identityService.GetProfileAsync(user.Id, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.InvalidCredentials);
if (profile.IsBlocked)
return Result.Failure<AuthResult>(AuthErrors.Blocked);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
refreshToken.RawToken,
refreshToken.ExpiresAt,
dto
)
);
}
}
@@ -1,12 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Auth.Login;
public sealed class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty();
RuleFor(x => x.Password).NotEmpty();
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Logout;
public sealed record LogoutCommand(string RawToken) : ICommand<Result>;
@@ -1,15 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Logout;
public sealed class LogoutCommandHandler(IRefreshTokenService refreshTokenService)
: ICommandHandler<LogoutCommand, Result>
{
public async Task<Result> Handle(LogoutCommand command, CancellationToken cancellationToken)
{
await refreshTokenService.RevokeAsync(command.RawToken, cancellationToken);
return Result.Success();
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Me;
public sealed record GetCurrentUserQuery : IQuery<Result<CurrentUserDto>>;
@@ -1,26 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Me;
public sealed class GetCurrentUserQueryHandler(
IIdentityService identityService,
ICurrentUser currentUser
) : IQueryHandler<GetCurrentUserQuery, Result<CurrentUserDto>>
{
public async Task<Result<CurrentUserDto>> Handle(
GetCurrentUserQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<CurrentUserDto>(AuthErrors.Unauthorized);
return Result.Success(new CurrentUserDto(profile.Id, profile.UserName, profile.Role));
}
}
@@ -1,6 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Refresh;
public sealed record RefreshCommand(string RawToken) : ICommand<Result<AuthResult>>;
@@ -1,44 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Refresh;
public sealed class RefreshCommandHandler(
IRefreshTokenService refreshTokenService,
IIdentityService identityService,
IJwtTokenService jwtTokenService
) : ICommandHandler<RefreshCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
RefreshCommand command,
CancellationToken cancellationToken
)
{
var rotated = await refreshTokenService.RotateAsync(command.RawToken, cancellationToken);
if (!rotated.IsSuccess)
return Result.Failure<AuthResult>(rotated.Error);
var profile = await identityService.GetProfileAsync(
rotated.Value.UserId,
cancellationToken
);
if (profile is null || profile.IsBlocked)
return Result.Failure<AuthResult>(AuthErrors.Unauthorized);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
rotated.Value.RawToken,
rotated.Value.ExpiresAt,
dto
)
);
}
}
@@ -1,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Register;
public sealed record RegisterCommand(string UserName, string Password)
: ICommand<Result<AuthResult>>;
@@ -1,51 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Auth.Register;
public sealed class RegisterCommandHandler(
IIdentityService identityService,
IJwtTokenService jwtTokenService,
IRefreshTokenService refreshTokenService,
ISiteSettings siteSettings
) : ICommandHandler<RegisterCommand, Result<AuthResult>>
{
public async Task<Result<AuthResult>> Handle(
RegisterCommand command,
CancellationToken cancellationToken
)
{
// Открытая регистрация должна быть явно включена админом; иначе учётки заводит только он.
if (!await siteSettings.IsRegistrationEnabledAsync(cancellationToken))
return Result.Failure<AuthResult>(AuthErrors.RegistrationDisabled);
var createResult = await identityService.CreateUserAsync(
command.UserName,
command.Password,
cancellationToken
);
if (!createResult.IsSuccess)
return Result.Failure<AuthResult>(createResult.Error);
var profile = await identityService.GetProfileAsync(createResult.Value, cancellationToken);
if (profile is null)
return Result.Failure<AuthResult>(AuthErrors.Unauthorized);
var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(
new AuthenticatedUser(profile.Id, profile.UserName, profile.Role)
);
var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken);
var dto = new CurrentUserDto(profile.Id, profile.UserName, profile.Role);
return Result.Success(
new AuthResult(
accessToken,
accessExpiresAt,
refreshToken.RawToken,
refreshToken.ExpiresAt,
dto
)
);
}
}
@@ -1,12 +0,0 @@
using FluentValidation;
namespace TeleWave.Application.Auth.Register;
public sealed class RegisterCommandValidator : AbstractValidator<RegisterCommand>
{
public RegisterCommandValidator()
{
RuleFor(x => x.UserName).NotEmpty().MinimumLength(3).MaximumLength(64);
RuleFor(x => x.Password).NotEmpty().MinimumLength(8);
}
}
@@ -1,27 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class AddBumperVariantCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddBumperVariantCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddBumperVariantCommand command,
CancellationToken cancellationToken
)
{
var template = await BumperTemplateLoader.LoadAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure<Guid>(BumperErrors.TemplateNotFound);
var variant = template.AddVariant(command.Name);
variant.SetLines(BumperTemplateLoader.DefaultLines());
return Result.Success(variant.Id);
}
}
@@ -1,104 +0,0 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed record ListBumperTemplatesQuery : IQuery<IReadOnlyList<BumperTemplateDto>>;
public sealed record CreateBumperTemplateCommand(string Name) : ICommand<Result<Guid>>;
/// <summary>Оформление блока: имя, шрифт и палитра. Меняет ревизию — заставки пересобираются.</summary>
public sealed record UpdateBumperTemplateCommand(Guid TemplateId, BumperStyle Style)
: ICommand<Result>;
public sealed record DeleteBumperTemplateCommand(Guid TemplateId) : ICommand<Result>;
public sealed record SetBumperTemplateAudioCommand(
Guid TemplateId,
string Extension,
double DurationSeconds
) : ICommand<Result>;
public sealed record ClearBumperTemplateAudioCommand(Guid TemplateId) : ICommand<Result>;
public sealed record SetBumperTemplateBackgroundCommand(Guid TemplateId, Guid ImageId)
: ICommand<Result>;
public sealed record ClearBumperTemplateBackgroundCommand(Guid TemplateId) : ICommand<Result>;
public sealed record AddBumperVariantCommand(Guid TemplateId, string Name) : ICommand<Result<Guid>>;
/// <summary>Полное содержимое подблока — строки редактор всегда присылает списком целиком.</summary>
public sealed record BumperVariantInput(
string Name,
BumperTrigger Trigger,
BumperBackground Background,
int Weight,
IReadOnlyList<BumperLineDto> Lines,
/// <summary>Ролик-подложка для фона <see cref="BumperBackground.Clip"/>.</summary>
Guid? BackgroundClipShowId = null
);
public sealed record UpdateBumperVariantCommand(
Guid TemplateId,
Guid VariantId,
BumperVariantInput Input
) : ICommand<Result>;
public sealed record RemoveBumperVariantCommand(Guid TemplateId, Guid VariantId) : ICommand<Result>;
/// <summary>
/// Рендер примера заставки. Канал нужен только для образцов подстановки: блок общий, но посмотреть
/// его надо глазами конкретного канала — иначе <c>{channel}</c> не на что заменить.
/// </summary>
public sealed record RenderBumperPreviewCommand(Guid TemplateId, Guid? ChannelId)
: ICommand<Result>;
public sealed class CreateBumperTemplateCommandValidator
: AbstractValidator<CreateBumperTemplateCommand>
{
public CreateBumperTemplateCommandValidator() =>
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
public sealed class UpdateBumperTemplateCommandValidator
: AbstractValidator<UpdateBumperTemplateCommand>
{
public UpdateBumperTemplateCommandValidator()
{
RuleFor(x => x.Style.Name).NotEmpty().MaximumLength(64);
RuleFor(x => x.Style.BackgroundColor).NotEmpty().MaximumLength(32);
RuleFor(x => x.Style.BackgroundColor2).NotEmpty().MaximumLength(32);
RuleFor(x => x.Style.AccentColor).NotEmpty().MaximumLength(32);
RuleFor(x => x.Style.TextColor).NotEmpty().MaximumLength(32);
}
}
public sealed class AddBumperVariantCommandValidator : AbstractValidator<AddBumperVariantCommand>
{
public AddBumperVariantCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
}
public sealed class UpdateBumperVariantCommandValidator
: AbstractValidator<UpdateBumperVariantCommand>
{
/// <summary>Больше шести строк в кадр не помещается ни при каком размере шрифта.</summary>
private const int MaxLines = 6;
public UpdateBumperVariantCommandValidator()
{
RuleFor(x => x.Input.Name).NotEmpty().MaximumLength(64);
RuleFor(x => x.Input.Weight).InclusiveBetween(0, 1000);
RuleFor(x => x.Input.Lines).NotNull().Must(l => l.Count <= MaxLines);
RuleForEach(x => x.Input.Lines)
.ChildRules(line => line.RuleFor(l => l.Text).NotEmpty().MaximumLength(120));
// Фон из ролика без самого ролика — это не «пока не выбрал», а заставка, которая молча
// отрендерится градиентом. Пусть выбор будет обязательным сразу.
RuleFor(x => x.Input.BackgroundClipShowId)
.NotNull()
.When(x => x.Input.Background == BumperBackground.Clip);
}
}
@@ -1,36 +0,0 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами.</summary>
public sealed record BumperLineDto(BumperLineStyle Style, BumperLineColor Color, string Text);
/// <summary>Подблок (текст-вариант): свои строки, фон и правило показа поверх оформления блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
string Name,
BumperTrigger Trigger,
BumperBackground Background,
int Weight,
IReadOnlyList<BumperLineDto> Lines,
/// <summary>Ролик-подложка для фона <see cref="BumperBackground.Clip"/>.</summary>
Guid? BackgroundClipShowId
);
/// <summary>Блок заставки: оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
public sealed record BumperTemplateDto(
Guid Id,
string Name,
BumperFont Font,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor,
Guid? BackgroundImageId,
bool HasAudio,
double? AudioDurationSeconds,
/// <summary>Во скольких врезках стыков используется блок — блоки общие, и это надо видеть до правки.</summary>
int UsageCount,
IReadOnlyList<BumperTextVariantDto> Variants
);

Some files were not shown because too many files have changed in this diff Show More