Compare commits

..
1 Commits
Author SHA1 Message Date
gitea-actions 19e7a97f42 coverage badge [skip ci] 2026-07-27 00:05:30 +00:00
761 changed files with 138 additions and 89783 deletions
-13
View File
@@ -1,13 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}
-109
View File
@@ -1,109 +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
# 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
# Нормализация громкости при обработке (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, v3 key), OMDb — omdbapi.com.
# Без ключа источник просто не показывается в админке (ручной режим работает всегда).
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
-177
View File
@@ -1,177 +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
# Строгая сборка: 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: 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]
permissions:
contents: write
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
- name: Test + coverage
working-directory: backend
run: >
dotnet test TeleWave.slnx
-c Release
--no-restore
--collect:"XPlat Code Coverage"
--results-directory ./coverage
--logger "console;verbosity=normal"
# Бейдж покрытия обновляем только для main (не для PR/форков): генерируем SVG и
# force-пушим в отдельную orphan-ветку badges — README ссылается на её raw-URL.
- name: Coverage badge
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
working-directory: backend
env:
TOKEN: ${{ secrets.COVERAGE_TOKEN || secrets.GITEA_TOKEN }}
run: |
dotnet tool install --global dotnet-reportgenerator-globaltool
export PATH="$PATH:$HOME/.dotnet/tools"
reportgenerator \
"-reports:coverage/**/coverage.cobertura.xml" \
"-targetdir:coverage/badges" \
"-reporttypes:Badges" \
"-assemblyfilters:+TeleWave.Domain;+TeleWave.Application" \
"-classfilters:-System.*"
BADGE=coverage/badges/badge_linecoverage.svg
test -f "$BADGE" || { echo "badge not generated"; exit 1; }
WORK=$(mktemp -d)
cp "$BADGE" "$WORK/coverage.svg"
cd "$WORK"
git init -q -b badges
git add coverage.svg
git -c user.name=gitea-actions -c user.email=actions@local \
commit -q -m "coverage badge [skip ci]"
git push -f \
"https://gitea-actions:${TOKEN}@gitea.hsrv.site/mrleo1nid/TeleWave.git" badges
# Анализ 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/**"
# Сканер подмешивает в сборку свои анализаторы, а в проекте 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).
- name: Test + coverage
if: env.SONAR_TOKEN != ''
run: >
dotnet test backend/TeleWave.slnx
-c Release
--no-build
--collect:"XPlat Code Coverage;Format=opencover"
--results-directory backend/coverage
--logger "console;verbosity=normal"
- 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 не задан — анализ пропущен."
-86
View File
@@ -1,86 +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/
-160
View File
@@ -1,160 +0,0 @@
# CLAUDE.md
Инструкции для Claude Code при работе в этом репозитории.
## Что это
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
хранилища на сервере. Админ управляет каналами и пользователями.
> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка;
> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS-сегменты), реестр изображений,
> метаданные (TMDb/OMDb), каталог каналов, планировщик эфира (реклама, ТВ-заставки-переходы,
> weekly-override'ы) и live-раздача HLS с публичным просмотром сетки. Telegram-бот сознательно
> не делаем. Дальнейшие крупные направления (напр. многоэкземплярное развёртывание, новые доменные
> фичи) — по сверке с пользователем.
Архитектура и код-конвенции — прямое зеркало [`D:\Github\PnvPanel`](../PnvPanel) (тот же автор,
тот же стек), но домен урезан под текущий объём фичи.
## Стек
- **Backend**: C# / .NET 10, ASP.NET Core Web API (Minimal API), Clean Architecture, CQRS через
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (NuGet-пакет, лёгкая
CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query — см. её README).
EF Core 10 + Npgsql, ASP.NET Core Identity + JWT (access + refresh), FluentValidation, Serilog.
Маппинг DTO вручную. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI.
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind
v4, Zustand (только auth+тема), react-hook-form + zod. pnpm, oxlint.
- **Инфра**: единый Docker-образ (API + статика SPA); PostgreSQL — **внешний сервер**, не в
compose (в отличие от PnvPanel).
## Архитектура — жёсткие правила
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
- **Domain** — без внешних зависимостей. Сейчас единственная сущность — `Auth/RefreshToken`
(rich model: приватные сеттеры, фабричный `Issue(...)`, поведенческий `Revoke(...)`).
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы:
`IAppDbContext`, `ICurrentUser`, `IIdentityService`, `IJwtTokenService`, `IRefreshTokenService`,
`IRoleService`). Зависит только от Domain — никаких `Npgsql`/`AspNetCore.Identity`, только их
абстракции.
- **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext<AppUser,
AppRole, Guid>`), `IdentityService`/`RoleService`/`JwtTokenService`/`RefreshTokenService`,
идемпотентный `DbInitializer` (сидинг ролей + админа).
- **Api** — Minimal API эндпоинты (`Endpoints/*Endpoints.cs`), middleware, DI composition root
(`Program.cs`).
Обязательно:
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер —
из `LiteCqrs.Net` (`ISender`/`ICommandHandler`/`IQueryHandler`), регистрация через
`AddLiteCqrs(...)` в `TeleWave.Application/DependencyInjection.cs`.
- Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями.
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
- Nullable reference types включены; `Directory.Build.props` включает
`TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать.
## Домен: роли, пользователи, аутентификация
Полноценной доменной модели (каналы/видео) пока нет — см. заметку в начале файла. Текущие
инварианты:
- **Роли** (`AppRole : IdentityRole<Guid>`, `Infrastructure/Identity/`) — динамические,
без квот (в отличие от PnvPanel: тут нет `MaxIpLimit`/`BillingEnabled` — при появлении
доменных фич квоты/лимиты добавляются на `AppRole`/`AppUser` по мере необходимости, не заранее).
У пользователя ровно одна роль. Системные роли `admin`/`user` (`IsSystem=true`) нельзя
переименовать/удалить — проверяется в `RoleService`. Смену роли (`ChangeUserRoleCommand`)
запрещено делать так, чтобы не осталось ни одного `admin` (`RoleErrors.CannotRemoveLastAdmin`).
- **Регистрация** — открытая, без гейта активации (в отличие от PnvPanel): `RegisterCommandHandler`
создаёт пользователя с ролью `user` и сразу выдаёт токены (auto-login). Если в будущем
понадобится модерация новых пользователей — добавлять отдельным полем/флагом по аналогии с
`IsActivated` в PnvPanel, не переиспользовать `IsBlocked`.
- **Блокировка** (`AppUser.IsBlocked`) — админ блокирует/разблокирует пользователя; вход
запрещён (`LoginCommandHandler`/`RefreshCommandHandler` проверяют флаг). Админ не может
заблокировать/удалить самого себя (`UserErrors.CannotBlockSelf`/`CannotDeleteSelf`).
- **Вход по `UserName`** (email не используется, SMTP не нужен). JWT — access (короткий TTL) +
refresh (httpOnly cookie `tw_refresh_token`, `SameSite=Strict`, ротация с обнаружением повторного
использования отозванного токена — см. `RefreshTokenService.RotateAsync`).
- **Сидинг**: идемпотентный `DbInitializer` создаёт системные роли + админа из env
(`AdminSeed__Username`/`Password`) при старте. Источник примера env — `.env.example`.
## Единый контейнер
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`).
Один origin, база API — относительный `/api`.
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet
runtime. Контейнер работает под непривилегированным пользователем образа (`USER $APP_UID`,
uid/gid 1654) — но **статически**, без gosu/entrypoint-скриптов и подгонки uid на старте: права
на bind-mount хранилища выставляет оператор на хосте (см. `docs/server-storage-setup.md` § 5).
Data Protection key-ring не заводим — секретов на диске пока нет (JWT-ключ — обычная конфигурация,
не шифруемый секрет at-rest); появятся зашифрованные данные (например API-ключи внешних
сервисов) — добавлять Data Protection по образцу PnvPanel.
- docker-compose: только `app` — PostgreSQL живёт вне compose (внешний сервер/хост, адрес и
креды — в `ConnectionStrings__Default` из `.env`; база и пользователь на нём создаются
заранее вручную, приложение их не сидит). Миграции применяются авто на старте
(`ApplyMigrationsAsync` в `Program.cs`) — прав `CREATEDB` не требуется, только DDL/DML в
уже существующей базе.
- Не вводи отдельный nginx-контейнер для статики — ломает требование единого контейнера.
- **TLS — внешний**; `app` отдаёт HTTP + доверяет `X-Forwarded-*` (`ForwardedHeaders`).
## Соглашения по коду
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (валидатор — где есть что
проверить). Папки — по фичам (`Auth/Login/`, `Admin/Roles/CreateRole/`, ...).
- DTO — суффикс `Dto`; тела запросов Api-эндпоинтов, не совпадающие с командой 1:1, — `Body`
(`sealed record` внизу файла эндпоинта).
- Один публичный тип на файл = имя файла (кроме `Body` в файле эндпоинтов).
- Ошибки — статические каталоги `XErrors` (`AuthErrors`, `RoleErrors`, `UserErrors`) с
`Error.Validation/NotFound/Conflict/Unauthorized/Forbidden(...)`.
- Секреты не логировать; логи — Serilog.
- Ошибки API — единый `application/problem+json` (см. `Api/Common/ResultExtensions.cs`).
- **Изображения — через общий реестр `Image`** (домен `Domain/Images`, галерея `features/admin/images`).
Любой новый функционал, где загружается или выбирается картинка, должен использовать контрол
`ImageGallery`/`GalleryBrowser` (пикер по категориям `ImageCategory`) и хранить ссылку на `ImageId`,
а не заводить своё файловое поле. Автоматически полученные картинки (например постер из метадаты)
тоже регистрируются как `Image`. Файлы лежат под `images/{id}{ext}`, отдаются по `/api/images/{id}`.
## Команды
Backend (из `backend/`):
```bash
dotnet build
# Форматирование — csharpier, версия закреплена в .config/dotnet-tools.json (один раз: dotnet tool restore).
# Глобально установленный csharpier может быть другой версии — вызывать только через dotnet:
dotnet csharpier format .
dotnet csharpier check .
# Юнит-тесты (по одному проекту за вызов — MSBuild не принимает несколько):
dotnet test tests/TeleWave.Domain.Tests
dotnet test tests/TeleWave.Application.Tests
# Интеграционные тесты (Testcontainers-Postgres) — нужен запущенный Docker; без него пропускаются:
dotnet test tests/TeleWave.Integration.Tests
dotnet run --project src/TeleWave.Api
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
dotnet ef database update --project src/TeleWave.Infrastructure --startup-project src/TeleWave.Api
```
Frontend (из `frontend/`):
```bash
pnpm install
pnpm dev
pnpm build
pnpm lint && pnpm typecheck
```
Инфраструктура:
```bash
docker compose up -d --build # только api; postgres — внешний, см. ConnectionStrings__Default
```
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
## Рабочие принципы
- Базовый вертикальный срез (каналы, планировщик, раздача HLS, обработка медиа) уже реализован —
правь его по месту. Но новые крупные направления с непринятыми архитектурными решениями (напр.
многоэкземплярное развёртывание, транскод-профили, новые доменные подсистемы) начинай только после
сверки с пользователем. При неоднозначности — вопрос пользователю, не предположение.
- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel.
- Не коммить и не пуши без явной просьбы.
- Отвечай пользователю на русском.
-46
View File
@@ -1,46 +0,0 @@
# syntax=docker/dockerfile:1
# ── Stage 1: сборка фронтенда (Vite → dist) ───────────────────────────────
FROM node:22-alpine AS frontend
WORKDIR /app/frontend
RUN corepack enable
COPY frontend/package.json frontend/pnpm-lock.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"]
-82
View File
@@ -1,82 +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)
[![coverage](https://gitea.hsrv.site/mrleo1nid/TeleWave/raw/branch/badges/coverage.svg)](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml)
[![quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=mrleo1nid_telewave&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,331 +0,0 @@
using System.Text;
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
public static partial class ChannelEndpoints
{
private static async Task<IResult> AddBumperTemplate(
Guid id,
AddBumperTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddBumperTemplateCommand(id, body.Name),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> UpdateBumperTemplate(
Guid id,
Guid templateId,
UpdateBumperTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateBumperTemplateCommand(
id,
templateId,
body.Name,
body.BackgroundColor,
body.BackgroundColor2,
body.AccentColor,
body.TextColor
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveBumperTemplate(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveBumperTemplateCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UploadTemplateAudio(
[AsParameters] BumperAudioUpload upload,
HttpRequest request,
IBumperTemplateStorage storage,
IAudioProbe probe,
ISender sender,
CancellationToken cancellationToken
)
{
if (
ResolveBumperExtension(upload.FileName, request, BumperFiles.AudioExtensions)
is not { } ext
)
return ChannelErrors.InvalidBumperFile.ToProblem();
await storage.SaveAudioAsync(upload.TemplateId, ext, request.Body, cancellationToken);
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
var path = storage.AudioPath(upload.TemplateId, ext);
var duration = path is null
? null
: await probe.TryGetDurationAsync(path, cancellationToken);
var result = await sender.Send(
new SetBumperTemplateAudioCommand(
upload.Id,
upload.TemplateId,
ext,
duration?.TotalSeconds ?? 0
),
cancellationToken
);
if (!result.IsSuccess)
await storage.DeleteAudioAsync(upload.TemplateId, cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ClearTemplateAudio(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ClearBumperTemplateAudioCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetTemplateBackground(
Guid id,
Guid templateId,
SetBumperTemplateBackgroundBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> ClearTemplateBackground(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ClearBumperTemplateBackgroundCommand(id, templateId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> AddBumperVariant(
Guid id,
Guid templateId,
AddBumperVariantBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddBumperTextVariantCommand(id, templateId, body.Name),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> UpdateBumperVariant(
Guid id,
Guid templateId,
Guid variantId,
UpdateBumperVariantBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateBumperTextVariantCommand(
id,
templateId,
variantId,
body.Name,
body.Kind,
body.NowLabel,
body.NextLabel,
body.Line1,
body.Line2,
body.Trigger,
body.Weight
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveBumperVariant(
Guid id,
Guid templateId,
Guid variantId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RemoveBumperTextVariantCommand(id, templateId, variantId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
private static async Task<IResult> RenderPreview(
Guid id,
Guid templateId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RenderBumperPreviewCommand(id, templateId),
cancellationToken
);
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
}
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(
Guid id,
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/channels/{id}/bumper/templates/{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>
/// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью
/// адресуется подблоком (см. 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? ResolveBumperExtension(
string fileName,
HttpRequest request,
IReadOnlySet<string> allowedExtensions
)
{
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
return null;
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return allowedExtensions.Contains(ext) ? ext : null;
}
}
public sealed record AddBumperTemplateBody(string Name);
public sealed record UpdateBumperTemplateBody(
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor
);
public sealed record SetBumperTemplateBackgroundBody(Guid ImageId);
public sealed record AddBumperVariantBody(string Name);
public sealed record UpdateBumperVariantBody(
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
);
/// <summary>
/// Адрес загружаемого звука: канал и блок из маршрута плюс имя исходного файла из query (по нему
/// проверяется расширение). Свёрнуто в один параметр — кроме него хендлеру нужны ещё запрос, два
/// сервиса, диспетчер и токен отмены, и плоским списком сигнатура перестаёт читаться.
/// </summary>
public sealed record BumperAudioUpload(Guid Id, Guid TemplateId, string FileName);
/// <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,247 +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.Trace;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
/// (<c>TemplateEndpoints</c>).
/// </summary>
public static partial 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
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
SetTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
ClearTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
PreviewPlaylist
);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
PreviewSegment
);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
UpdateBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
RemoveBumperVariant
)
.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>();
return app;
}
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.BumpersEnabled,
body.Bumper,
body.FillerAssetId
),
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,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
);
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
@@ -1,163 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library.Collections;
using TeleWave.Application.Library.Collections.AddCollectionShow;
using TeleWave.Application.Library.Collections.CreateCollection;
using TeleWave.Application.Library.Collections.DeleteCollection;
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.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);
return app;
}
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);
@@ -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,189 +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.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.UpdateGroup;
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.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);
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> UpdateGroup(
Guid id,
UpdateGroupBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter),
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> 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);
public sealed record FindCandidatesBody(GroupFilter? Filter);
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,170 +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")
.WithTags("Admin.Junctions")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin
.MapGet("/channels/{channelId:guid}/junctions", List)
.Produces<IReadOnlyList<JunctionTemplateDto>>();
admin
.MapPost("/channels/{channelId:guid}/junctions", Create)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/junctions/{junctionId:guid}", Rename)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/junctions/{junctionId:guid}", Delete)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/junctions/{junctionId:guid}/elements", AddElement)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/junctions/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/junctions/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/junctions/{junctionId:guid}/order", Reorder)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> List(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListJunctionsQuery(channelId), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Create(
Guid channelId,
JunctionNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateJunctionCommand(channelId, body.Name),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/junctions/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> Rename(
Guid junctionId,
JunctionNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new RenameJunctionCommand(junctionId, body.Name),
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.ElementIdsInOrder),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record JunctionNameBody(string Name);
public sealed record JunctionElementKindBody(JunctionElementKind Kind);
public sealed record ReorderJunctionBody(IReadOnlyList<Guid> ElementIdsInOrder);
@@ -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,216 +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.Register;
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);
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
return app;
}
/// <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);
var result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken
);
if (!result.IsSuccess)
{
await storage.DeleteUploadAsync(token, cancellationToken);
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> 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);
@@ -1,146 +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.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>();
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /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();
}
}
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,186 +0,0 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
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.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.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
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();
}
private static async Task<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowCommand(id), 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();
}
}
public sealed record AddEpisodeBody(Guid MediaAssetId);
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,191 +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; сегменты этой проверки не делают (слишком часто).
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (tokens.Validate(request.Cookies[StreamCookieName]) 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);
}
private static string Render(LivePlaylistDto playlist)
{
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"
);
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\n"
);
}
return sb.ToString();
}
}
/// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
@@ -1,296 +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.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
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
.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
.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> 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> 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 CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody(
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
);
-148
View File
@@ -1,148 +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 builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
}
});
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.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
// Раздача статики 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,24 +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"
}
}
-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,7 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Добавить новый блок заставки на канал (звук/фон загружаются отдельно).</summary>
public sealed record AddBumperTemplateCommand(Guid ChannelId, string Name) : ICommand<Result<Guid>>;
@@ -1,28 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class AddBumperTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddBumperTemplateCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddBumperTemplateCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var name = string.IsNullOrWhiteSpace(command.Name)
? $"Заставка {channel.BumperTemplates.Count + 1}"
: command.Name.Trim();
var template = channel.AddBumperTemplate(name);
return Result.Success(template.Id);
}
}
@@ -1,8 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Добавить подблок (текст-вариант) в блок заставки.</summary>
public sealed record AddBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, string Name)
: ICommand<Result<Guid>>;
@@ -1,34 +0,0 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class AddBumperTextVariantCommandHandler(IAppDbContext dbContext)
: ICommandHandler<AddBumperTextVariantCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
AddBumperTextVariantCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<Guid>(ChannelErrors.NotFound);
var template = channel.FindBumperTemplate(command.TemplateId);
if (template is null)
return Result.Failure<Guid>(ChannelErrors.BumperTemplateNotFound);
var name = string.IsNullOrWhiteSpace(command.Name)
? $"Текст {template.Variants.Count + 1}"
: command.Name.Trim();
var variant = template.AddVariant(name);
return Result.Success(variant.Id);
}
}
@@ -1,24 +0,0 @@
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Инфраструктурные настройки рендера ТВ-заставок (общие для всех каналов): разрешение, пути к
/// шрифтам, версия шаблона. Оформление и правила (цвета, подписи, длительность, интервал) задаются
/// на каждом канале — см. <c>Channel.UpdateBumperSettings</c>.
/// </summary>
public sealed class BumperOptions
{
public const string SectionName = "Bumpers";
public int Width { get; init; } = 1280;
public int Height { get; init; } = 720;
/// <summary>Пути к TTF-шрифтам с кириллицей внутри контейнера (см. Dockerfile, fonts-dejavu-core).</summary>
public string FontFileSans { get; init; } =
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";
public string FontFileSerif { get; init; } =
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf";
/// <summary>Версия шаблона рендера — входит в кэш-ключ заставки; меняй при правке ЛОГИКИ рендера
/// (не оформления канала), чтобы пересобрать уже отрендеренные заставки.</summary>
public int TemplateVersion { get; init; } = 1;
}
@@ -1,18 +0,0 @@
using System.Security.Cryptography;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Детерминированный id ассета-превью для подблока заставки: один и тот же на каждый повторный рендер,
/// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые.
/// </summary>
public static class BumperPreview
{
/// <summary>
/// Свёртка id подблока в стабильный id превью. Не защита — просто способ получить из одного GUID
/// другой, воспроизводимо; берём SHA-256 и первые 16 байт, чтобы в коде не оставалось вызовов
/// сломанных хеш-функций, которые потом приходится каждый раз объяснять сканерам.
/// </summary>
public static Guid AssetId(Guid variantId) =>
new(SHA256.HashData(variantId.ToByteArray()).AsSpan(0, 16));
}
@@ -1,43 +0,0 @@
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (пути к постеру/фону/звуку,
/// названия шоу). Общая точка для фонового рендерера заставок расписания и превью в админке.
/// </summary>
public static class BumperSpecFactory
{
public static BumperRenderSpec Build(
BumperOptions bumper,
BumperFont font,
BumperTemplate template,
BumperTextVariant variant,
int alignedDurationSeconds,
BumperSpecInputs inputs
)
{
var free = variant.Kind == BumperTextKind.Free;
return new BumperRenderSpec(
alignedDurationSeconds,
bumper.Width,
bumper.Height,
template.BackgroundColor,
template.BackgroundColor2,
template.AccentColor,
template.TextColor,
font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
free ? "" : variant.NowLabel,
free ? "" : inputs.FromName,
free ? "" : variant.NextLabel,
free ? "" : inputs.ToName,
inputs.BackgroundAbsolutePath,
inputs.AudioPath,
inputs.PosterAbsolutePath,
free,
variant.Line1,
variant.Line2
);
}
}
@@ -1,15 +0,0 @@
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Уже разрешённые входы рендера заставки: названия шоу «из/в» и пути к файлам. Разрешает их
/// вызывающий (генератор эфира — по реальной паре соседей, превью — по образцам канала), а
/// <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
/// </summary>
public sealed record BumperSpecInputs(
string FromName,
string ToName,
string? AudioPath = null,
/// <summary>Постер «следующего» шоу как фон; в превью не подставляется — шоу ещё неизвестно.</summary>
string? PosterAbsolutePath = null,
string? BackgroundAbsolutePath = null
);
@@ -1,113 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил только
/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку,
/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения,
/// воркер лишь крутит ffmpeg.
/// </summary>
public sealed class BumperSpecLoader(
IAppDbContext dbContext,
IBumperTemplateStorage bumperStorage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
)
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Спецификация заставки для ассета, либо null если восстановить её уже нельзя.</summary>
public async Task<BumperRenderSpec?> LoadAsync(
Guid assetId,
CancellationToken cancellationToken
)
{
var cache = await dbContext
.BumperAssets.AsNoTracking()
.Where(b => b.MediaAssetId == assetId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (cache is null)
return null;
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == cache.ChannelId, cancellationToken);
var template = channel?.BumperTemplates.FirstOrDefault(t => t.Id == cache.TemplateId);
var variant = template?.Variants.FirstOrDefault(v => v.Id == cache.VariantId);
if (channel is null || template is null || variant is null)
return null;
var names = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
// Постер шоу-получателя как фон — только для «Сейчас/Далее».
string? posterPath = null;
if (variant.Kind == BumperTextKind.NowNext)
posterPath = await ResolveShowPosterAsync(cache.ToShowId, cancellationToken);
var bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
var aligned = BumperDuration.Aligned(
BumperDuration.TemplateSeconds(template),
_segmentSeconds
);
return BumperSpecFactory.Build(
_bumper,
channel.BumperFont,
template,
variant,
aligned,
new BumperSpecInputs(
names.GetValueOrDefault(cache.FromShowId, "…"),
names.GetValueOrDefault(cache.ToShowId, "…"),
bumperStorage.AudioPath(template.Id, template.AudioExtension),
posterPath,
bgPath
)
);
}
private async Task<string?> ResolveShowPosterAsync(
Guid showId,
CancellationToken cancellationToken
)
{
var posterImageId = await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId && s.PosterImageId != null)
.Select(s => s.PosterImageId)
.FirstOrDefaultAsync(cancellationToken);
return await ResolveImagePathAsync(posterImageId, cancellationToken);
}
private async Task<string?> ResolveImagePathAsync(
Guid? imageId,
CancellationToken cancellationToken
)
{
if (imageId is not { } id)
return null;
var ext = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == id)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return ext is null ? null : imageStore.ResolvePath(id, ext);
}
}
@@ -1,8 +0,0 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.Bumpers;
/// <summary>Удалить загруженный звук блока (вернуться к синтезированному джинглу).</summary>
public sealed record ClearBumperTemplateAudioCommand(Guid ChannelId, Guid TemplateId)
: ICommand<Result>;

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