Compare commits
112
Commits
9d1c6d2fc3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f609db4e9 | ||
|
|
7a164dbd90 | ||
|
|
bed6d4fcfc | ||
|
|
88cfe3716b | ||
|
|
89b68a26e7 | ||
|
|
cbeee92d26 | ||
|
|
3dbe0b7698 | ||
|
|
4a2e6ac7f1 | ||
|
|
08f56e14c2 | ||
|
|
d6bc2b15ac | ||
|
|
8a439f1351 | ||
|
|
99821d3c76 | ||
|
|
54cc4862b2 | ||
|
|
ec957fe55c | ||
|
|
2b03e43a83 | ||
|
|
bd3ced3637 | ||
|
|
375e0a810b | ||
|
|
7163a0b937 | ||
|
|
789357481d | ||
|
|
9cd364b174 | ||
|
|
01ba48e163 | ||
|
|
bee655ec1c | ||
|
|
b9d96c915c | ||
|
|
e983374dec | ||
|
|
de48317f7d | ||
|
|
926a20020f | ||
|
|
398b60facc | ||
|
|
5032614773 | ||
|
|
3fecd81333 | ||
|
|
1758a93011 | ||
|
|
10e05b8689 | ||
|
|
bd89abf24b | ||
|
|
8119305c36 | ||
|
|
6be373476c | ||
|
|
35cb4b09dc | ||
|
|
6f74e66f0c | ||
|
|
281d081e6b | ||
|
|
c4a94443f7 | ||
|
|
775f287c29 | ||
|
|
5952ba4536 | ||
|
|
fbfc6e677e | ||
|
|
c67d1f7752 | ||
|
|
46c0169dea | ||
|
|
3601404383 | ||
|
|
ac41111fa0 | ||
|
|
6c85db2d60 | ||
|
|
3908b6e722 | ||
|
|
f924891f3b | ||
|
|
f895e1ddbc | ||
|
|
30a38d163c | ||
|
|
4a500583e7 | ||
|
|
9db54a4625 | ||
|
|
2e76fa1452 | ||
|
|
6ca629c13a | ||
|
|
287ed5aa12 | ||
|
|
ef5c56f04b | ||
|
|
05e9919a7b | ||
|
|
f15b2fefcd | ||
|
|
d0bc0465e7 | ||
|
|
d859a9894c | ||
|
|
e48838b69d | ||
|
|
0b9ea9f0f4 | ||
|
|
fe11034b16 | ||
|
|
f7e7b5f7c3 | ||
|
|
7bee84c548 | ||
|
|
e0b85807df | ||
|
|
9f86016a2c | ||
|
|
16b3c485d7 | ||
|
|
1afe01aed8 | ||
|
|
3d2ae20368 | ||
|
|
8ae8eebc12 | ||
|
|
779f322f62 | ||
|
|
ba3721eb92 | ||
|
|
ee0b4d2d01 | ||
|
|
e55161f8f9 | ||
|
|
986cf02b0d | ||
|
|
ef3328ec6f | ||
|
|
a284066da5 | ||
|
|
259f4041c3 | ||
|
|
36c70a61df | ||
|
|
860cde19a3 | ||
|
|
61f934d19f | ||
|
|
96ff2cc7ab | ||
|
|
062f4aa200 | ||
|
|
b8d6b6aad2 | ||
|
|
f22aee4685 | ||
|
|
0fd4e762f4 | ||
|
|
a302750de6 | ||
|
|
6306919ecd | ||
|
|
bef029b858 | ||
|
|
6fe6404e61 | ||
|
|
212899bc2b | ||
|
|
f8923b5275 | ||
|
|
97fa77287f | ||
|
|
5ad28b2966 | ||
|
|
1a7f73a5bd | ||
|
|
91af589547 | ||
|
|
24fdbdf680 | ||
|
|
4774083dc9 | ||
|
|
67e8b941bb | ||
|
|
8841059070 | ||
|
|
be6947af06 | ||
|
|
790d01b587 | ||
|
|
464355434b | ||
|
|
a8b5a8f693 | ||
|
|
986f7ff52d | ||
|
|
07c43a6875 | ||
|
|
2045b1a3f2 | ||
|
|
b80163b6b4 | ||
|
|
088af2318e | ||
|
|
0606ea3e6e | ||
|
|
0442056367 |
@@ -0,0 +1,22 @@
|
|||||||
|
# Базовые правила для редакторов — чтобы IDE не ставила свои отступы и концы строк до того, как
|
||||||
|
# файл дойдёт до форматтера. Итоговый стиль всё равно задают csharpier (.config/dotnet-tools.json)
|
||||||
|
# и Prettier (frontend/.prettierrc.json); значения здесь совпадают с их настройками намеренно —
|
||||||
|
# оба читают .editorconfig, и расхождение развернуло бы форматирование в другую сторону.
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
max_line_length = 100
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
# В markdown два пробела в конце строки — это перенос, обрезать их нельзя.
|
||||||
|
trim_trailing_whitespace = false
|
||||||
+10
-1
@@ -32,6 +32,9 @@ Storage__LiveWindowSegments=10
|
|||||||
Storage__KeepOriginals=false
|
Storage__KeepOriginals=false
|
||||||
# Порог свободного места, ниже которого загрузка отклоняется (10 ГБ).
|
# Порог свободного места, ниже которого загрузка отклоняется (10 ГБ).
|
||||||
Storage__MinFreeSpaceBytes=10737418240
|
Storage__MinFreeSpaceBytes=10737418240
|
||||||
|
# Через сколько часов простоя убирать брошенное в uploads/ (след оборванной заливки или рестарта
|
||||||
|
# посреди неё). Идущую загрузку не задевает — отсчёт от времени последней записи. 0 — не убирать.
|
||||||
|
# Storage__StaleUploadHours=24
|
||||||
# TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после
|
# TTL stream-токена (cookie tw_stream), минуты. Короткий срок ограничивает окно доступа после
|
||||||
# блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30.
|
# блокировки/логаута; фронт перевыпускает cookie, пока идёт просмотр. По умолчанию 30.
|
||||||
# Storage__StreamTokenMinutes=30
|
# Storage__StreamTokenMinutes=30
|
||||||
@@ -63,6 +66,9 @@ Media__TranscodeThreads=3
|
|||||||
Media__MaxParallelTranscodes=1
|
Media__MaxParallelTranscodes=1
|
||||||
# Период опроса каталога inbox/ сканером, секунды.
|
# Период опроса каталога inbox/ сканером, секунды.
|
||||||
Media__InboxScanSeconds=15
|
Media__InboxScanSeconds=15
|
||||||
|
# Заводить фильм по файлу из inbox/, когда имя уверенно опознано в источнике метаданных.
|
||||||
|
# Неуверенное совпадение остаётся человеку: файл станет ассетом без шоу и попадёт в разбор.
|
||||||
|
Media__InboxAutoMovies=true
|
||||||
# Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости.
|
# Нормализация громкости при обработке (EBU R128 loudnorm) — все ролики одинаковой громкости.
|
||||||
Media__NormalizeLoudness=true
|
Media__NormalizeLoudness=true
|
||||||
# Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить).
|
# Целевая громкость, LUFS (−16 типично для стриминга; тише — уменьшить).
|
||||||
@@ -87,8 +93,11 @@ Media__LoudnessTargetLufs=-16
|
|||||||
# Bumpers__TemplateVersion=1
|
# Bumpers__TemplateVersion=1
|
||||||
|
|
||||||
# ── Метаданные шоу/серий (TMDb / OMDb) ─────────────────────────────────────
|
# ── Метаданные шоу/серий (TMDb / OMDb) ─────────────────────────────────────
|
||||||
# Ключи бесплатные: TMDb — themoviedb.org (Settings → API, v3 key), OMDb — omdbapi.com.
|
# Ключи бесплатные: TMDb — themoviedb.org (Settings → API), OMDb — omdbapi.com.
|
||||||
# Без ключа источник просто не показывается в админке (ручной режим работает всегда).
|
# Без ключа источник просто не показывается в админке (ручной режим работает всегда).
|
||||||
|
# TMDb принимает оба вида учётных данных, различать их не нужно — подставляй что есть:
|
||||||
|
# «API Key (v3 auth)» — 32 hex-символа, уходит параметром api_key;
|
||||||
|
# «API Read Access Token (v4 auth)» — длинный JWT с точками, уходит заголовком Bearer.
|
||||||
Metadata__Language=ru-RU
|
Metadata__Language=ru-RU
|
||||||
Metadata__Tmdb__ApiKey=
|
Metadata__Tmdb__ApiKey=
|
||||||
Metadata__Omdb__ApiKey=
|
Metadata__Omdb__ApiKey=
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Коммиты, которые правили только оформление: перевод концов строк в LF и первые прогоны
|
||||||
|
# csharpier/Prettier. Без этого списка `git blame` на половине файлов показывал бы их вместо
|
||||||
|
# автора реальной правки.
|
||||||
|
#
|
||||||
|
# Включить локально (в .git/config, а не в репозитории — потому команду надо выполнить у себя):
|
||||||
|
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||||
|
#
|
||||||
|
# Добавлять сюда только чисто механические коммиты, полным SHA, по одному на строку:
|
||||||
|
# git rev-parse HEAD >> .git-blame-ignore-revs
|
||||||
|
|
||||||
|
# Перевод дерева в LF.
|
||||||
|
0442056367cb6cd3b38964dacf75f44f123e11ac
|
||||||
|
|
||||||
|
# Первый прогон Prettier по фронтенду.
|
||||||
|
0606ea3e6ee4a4fd7faf8d1b054a316633442e98
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Концы строк в репозитории — всегда LF. Бэкенд и так лежал в LF, фронтенд — в CRLF; Prettier
|
||||||
|
# (endOfLine: lf по умолчанию) выровнял его, и этот файл не даёт разъехаться обратно: без него
|
||||||
|
# при core.autocrlf=true у другого разработчика CRLF вернулись бы в коммит.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Бинарники не трогаем.
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.ico binary
|
||||||
|
*.woff2 binary
|
||||||
+34
-41
@@ -21,6 +21,14 @@ jobs:
|
|||||||
- name: Restore
|
- name: Restore
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: dotnet restore TeleWave.slnx
|
run: dotnet restore TeleWave.slnx
|
||||||
|
# Форматирование проверяем до сборки: падает за секунду и сразу показывает, что чинить.
|
||||||
|
# Версия csharpier прибита в .config/dotnet-tools.json — глобальная копия разработчика на CI
|
||||||
|
# не влияет, иначе её минорка переформатировала бы репозиторий в свой стиль.
|
||||||
|
- name: Format check
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
dotnet tool restore
|
||||||
|
dotnet csharpier check .
|
||||||
# Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
|
# Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
|
||||||
- name: Build (Release)
|
- name: Build (Release)
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
@@ -38,6 +46,9 @@ jobs:
|
|||||||
- name: Install
|
- name: Install
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Format check
|
||||||
|
working-directory: frontend
|
||||||
|
run: pnpm format:check
|
||||||
- name: Lint
|
- name: Lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
@@ -51,8 +62,6 @@ jobs:
|
|||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build-backend, build-frontend]
|
needs: [build-backend, build-frontend]
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
@@ -61,44 +70,16 @@ jobs:
|
|||||||
- name: Restore
|
- name: Restore
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: dotnet restore TeleWave.slnx
|
run: dotnet restore TeleWave.slnx
|
||||||
- name: Test + coverage
|
# Покрытие здесь не собираем: его считает джоба sonar своим прогоном (в формате opencover),
|
||||||
|
# а бейдж покрытия в README берётся из SonarCloud.
|
||||||
|
- name: Test
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: >
|
run: >
|
||||||
dotnet test TeleWave.slnx
|
dotnet test TeleWave.slnx
|
||||||
-c Release
|
-c Release
|
||||||
--no-restore
|
--no-restore
|
||||||
--collect:"XPlat Code Coverage"
|
|
||||||
--results-directory ./coverage
|
|
||||||
--logger "console;verbosity=normal"
|
--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 вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты
|
# Анализ SonarCloud вместе с покрытием: один прогон собирает решение под сканером, гоняет тесты
|
||||||
# и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана
|
# и отправляет результат. Только push в main — декорация pull request'ов у SonarCloud завязана
|
||||||
# на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте.
|
# на GitHub/GitLab, из Gitea она не работает, и анализ PR только засорял бы ветки в проекте.
|
||||||
@@ -145,7 +126,12 @@ jobs:
|
|||||||
/d:sonar.token="$SONAR_TOKEN" \
|
/d:sonar.token="$SONAR_TOKEN" \
|
||||||
/d:sonar.cs.opencover.reportsPaths="**/coverage/**/coverage.opencover.xml" \
|
/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.exclusions="frontend/node_modules/**,frontend/dist/**,frontend/src/routeTree.gen.ts,backend/src/TeleWave.Infrastructure/Migrations/**" \
|
||||||
/d:sonar.coverage.exclusions="backend/src/TeleWave.Infrastructure/Migrations/**,frontend/**"
|
/d:sonar.coverage.exclusions="backend/src/TeleWave.Infrastructure/Migrations/**,frontend/**" \
|
||||||
|
/d:sonar.cpd.exclusions="frontend/src/shared/lib/locales/**"
|
||||||
|
|
||||||
|
# Локали исключены из поиска дублей намеренно: ru.ts и en.ts обязаны совпадать ключ в ключ —
|
||||||
|
# это не копипаста, а единственный способ не потерять перевод, и «устранение» такого дубля
|
||||||
|
# означало бы отказ от второго языка.
|
||||||
|
|
||||||
# Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true —
|
# Сканер подмешивает в сборку свои анализаторы, а в проекте TreatWarningsAsErrors=true —
|
||||||
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
|
# любое замечание Sonar роняло бы сборку вместо того, чтобы приехать в отчёт. Строгая
|
||||||
@@ -156,15 +142,22 @@ jobs:
|
|||||||
|
|
||||||
# Формат opencover, а не cobertura по умолчанию: C#-анализатор Sonar читает именно его.
|
# Формат opencover, а не cobertura по умолчанию: C#-анализатор Sonar читает именно его.
|
||||||
# Интеграционные тесты без Docker пропускаются сами (см. PostgresFixture).
|
# Интеграционные тесты без Docker пропускаются сами (см. PostgresFixture).
|
||||||
|
#
|
||||||
|
# Проекты гоняются по одному, а не одним прогоном по решению: при общем прогоне сборщик
|
||||||
|
# покрытия теряет попадания — отчёт приезжает без модуля TeleWave.Application, и в Sonar
|
||||||
|
# покрытые тестами файлы выглядят нулевыми. Отчёты складываются в один каталог, сканер
|
||||||
|
# подбирает их маской.
|
||||||
- name: Test + coverage
|
- name: Test + coverage
|
||||||
if: env.SONAR_TOKEN != ''
|
if: env.SONAR_TOKEN != ''
|
||||||
run: >
|
run: |
|
||||||
dotnet test backend/TeleWave.slnx
|
for project in Domain Application Integration; do
|
||||||
-c Release
|
dotnet test "backend/tests/TeleWave.$project.Tests" \
|
||||||
--no-build
|
-c Release \
|
||||||
--collect:"XPlat Code Coverage;Format=opencover"
|
--no-build \
|
||||||
--results-directory backend/coverage
|
--collect:"XPlat Code Coverage;Format=opencover" \
|
||||||
--logger "console;verbosity=normal"
|
--results-directory backend/coverage \
|
||||||
|
--logger "console;verbosity=normal"
|
||||||
|
done
|
||||||
|
|
||||||
- name: End analysis
|
- name: End analysis
|
||||||
if: env.SONAR_TOKEN != ''
|
if: env.SONAR_TOKEN != ''
|
||||||
|
|||||||
@@ -84,3 +84,8 @@ dist-ssr/
|
|||||||
# Отчёты покрытия (артефакт dotnet test / CI)
|
# Отчёты покрытия (артефакт dotnet test / CI)
|
||||||
coverage/
|
coverage/
|
||||||
TestResults/
|
TestResults/
|
||||||
|
|
||||||
|
# ---> TeleWave
|
||||||
|
# Отладочные дампы каналов: архивы кладёт сюда бэкенд (секция Debug:ExportDirectory).
|
||||||
|
debug-exports/*
|
||||||
|
!debug-exports/.gitkeep
|
||||||
|
|||||||
@@ -4,157 +4,125 @@
|
|||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
|
**TeleWave** — сервис онлайн-каналов: зритель смотрит сетку каналов, видео отдаётся из хранилища
|
||||||
хранилища на сервере. Админ управляет каналами и пользователями.
|
на сервере. Админ ведёт библиотеку, каналы и эфир.
|
||||||
|
|
||||||
> Текущее состояние — **рабочий вертикальный срез**: вход/регистрация, роли, пользователи, админка;
|
Работает: аутентификация, роли, пользователи; библиотека шоу/серий, коллекции, жанры, метаданные
|
||||||
> библиотека шоу/серий, загрузка и обработка медиа (ffmpeg → HLS-сегменты), реестр изображений,
|
(TMDb/OMDb), обработка медиа (ffmpeg → HLS), изображения; группы, шаблон сетки, стыки и заставки,
|
||||||
> метаданные (TMDb/OMDb), каталог каналов, планировщик эфира (реклама, ТВ-заставки-переходы,
|
автосборка по профилям, обмен конфигурацией файлом и запросом к ИИ; генерация ленты, live-раздача,
|
||||||
> weekly-override'ы) и live-раздача HLS с публичным просмотром сетки. Telegram-бот сознательно
|
публичный просмотр, статистика хранилища. Telegram-бот расписания (подписки на оповещения по каналам, прокси, опрос или вебхук). Конвенции — зеркало
|
||||||
> не делаем. Дальнейшие крупные направления (напр. многоэкземплярное развёртывание, новые доменные
|
[`PnvPanel`](../PnvPanel) (тот же автор).
|
||||||
> фичи) — по сверке с пользователем.
|
|
||||||
|
|
||||||
Архитектура и код-конвенции — прямое зеркало [`D:\Github\PnvPanel`](../PnvPanel) (тот же автор,
|
**Спеки в `docs/` — источник правды по домену**, читай до правок в подсистеме:
|
||||||
тот же стек), но домен урезан под текущий объём фичи.
|
[`tv-scheduler-architecture.md`](docs/tv-scheduler-architecture.md) (группы, сетка, слоты, стыки,
|
||||||
|
заставки, планировщик, автосборка, обмен конфигурацией — главный документ),
|
||||||
|
[`media-storage-and-streaming.md`](docs/media-storage-and-streaming.md) и
|
||||||
|
[`server-storage-setup.md`](docs/server-storage-setup.md) (медиа и хранилище).
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
- **Backend**: C# / .NET 10, ASP.NET Core Web API (Minimal API), Clean Architecture, CQRS через
|
- **Backend**: C# / .NET 10, ASP.NET Core Minimal API, Clean Architecture, CQRS через
|
||||||
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (NuGet-пакет, лёгкая
|
[`LiteCqrs.Net`](https://github.com/mrleo1nid/LiteCqrs.Net) (лёгкая альтернатива MediatR с явным
|
||||||
CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query — см. её README).
|
разделением Command/Query — см. README). EF Core 10 + Npgsql, Identity + JWT, FluentValidation,
|
||||||
EF Core 10 + Npgsql, ASP.NET Core Identity + JWT (access + refresh), FluentValidation, Serilog.
|
Serilog, маппинг DTO вручную, OpenAPI + Scalar UI, тесты на xUnit + NSubstitute.
|
||||||
Маппинг DTO вручную. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI.
|
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router (файловые роуты, `routeTree.gen.ts`
|
||||||
- **Frontend**: React 19 + Vite + TS, TanStack Query/Router, shadcn-стиль поверх Radix + Tailwind
|
генерируется сборкой), shadcn поверх Radix + Tailwind v4, Zustand (только auth+тема),
|
||||||
v4, Zustand (только auth+тема), react-hook-form + zod. pnpm, oxlint.
|
react-hook-form + zod, i18n (ru/en), pnpm + oxlint + prettier.
|
||||||
- **Инфра**: единый Docker-образ (API + статика SPA); PostgreSQL — **внешний сервер**, не в
|
|
||||||
compose (в отличие от PnvPanel).
|
|
||||||
|
|
||||||
## Архитектура — жёсткие правила
|
## Архитектура — жёсткие правила
|
||||||
|
|
||||||
Слои и направление зависимостей: **Api → Infrastructure → Application → Domain** (внутрь).
|
Зависимости строго внутрь: **Api → Infrastructure → Application → Domain**.
|
||||||
|
|
||||||
- **Domain** — без внешних зависимостей. Сейчас единственная сущность — `Auth/RefreshToken`
|
- **Domain** — без внешних зависимостей: `Auth`, `Library` (шоу, серии, коллекции, жанры), `Media`,
|
||||||
(rich model: приватные сеттеры, фабричный `Issue(...)`, поведенческий `Revoke(...)`).
|
`Images`, `Programming` (группы, сетка, слоты, стыки + **чистый планировщик** `Planning`),
|
||||||
- **Application** — CQRS-хендлеры, DTO, валидаторы, **порты** (интерфейсы:
|
`Broadcast` (каналы, заставки, лента, live), `Settings`. Модель rich: приватные сеттеры,
|
||||||
`IAppDbContext`, `ICurrentUser`, `IIdentityService`, `IJwtTokenService`, `IRefreshTokenService`,
|
фабрики `Create`, поведенческие методы.
|
||||||
`IRoleService`). Зависит только от Domain — никаких `Npgsql`/`AspNetCore.Identity`, только их
|
- **Application** — CQRS-хендлеры, DTO, валидаторы и **порты** (`Common/Interfaces/I*.cs`:
|
||||||
абстракции.
|
`IAppDbContext`, `ICurrentUser`, `IMediaProcessor`, `IMediaStorage`, `IBumperRenderer`,
|
||||||
|
`IMetadataProvider`, `IImageStore`, `IStorageInspector`, …). Зависит только от Domain: никаких
|
||||||
|
`Npgsql`/`AspNetCore.Identity`, лишь их абстракции.
|
||||||
- **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext<AppUser,
|
- **Infrastructure** — реализации портов: EF Core (`AppDbContext : IdentityDbContext<AppUser,
|
||||||
AppRole, Guid>`), `IdentityService`/`RoleService`/`JwtTokenService`/`RefreshTokenService`,
|
AppRole, Guid>`, конфигурации в `Persistence/Configurations`), ffmpeg, хранилище, метаданные,
|
||||||
идемпотентный `DbInitializer` (сидинг ролей + админа).
|
идемпотентный `DbInitializer` (роли + админ из env `AdminSeed__*`).
|
||||||
- **Api** — Minimal API эндпоинты (`Endpoints/*Endpoints.cs`), middleware, DI composition root
|
- **Api** — Minimal API (`Endpoints/*Endpoints.cs`), middleware, DI composition root (`Program.cs`).
|
||||||
(`Program.cs`).
|
|
||||||
|
|
||||||
Обязательно:
|
Обязательно:
|
||||||
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер —
|
- Команды меняют состояние в транзакции (`UnitOfWorkBehavior`); запросы только читают. Диспетчер —
|
||||||
из `LiteCqrs.Net` (`ISender`/`ICommandHandler`/`IQueryHandler`), регистрация через
|
`ISender`/`ICommandHandler`/`IQueryHandler`, регистрация в `Application/DependencyInjection.cs`.
|
||||||
`AddLiteCqrs(...)` в `TeleWave.Application/DependencyInjection.cs`.
|
- Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями. Валидация —
|
||||||
- Управляемые ошибки — через `Result<T>` (`Common/Models/Result.cs`), не исключениями.
|
FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
|
||||||
- Валидация — FluentValidation через `ValidationBehavior`; хендлер не перепроверяет формат ввода.
|
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP; никаких `.Result`/`.Wait()`.
|
||||||
- Всё I/O асинхронно, `CancellationToken` пробрасывается до EF/HTTP. Никаких `.Result`/`.Wait()`.
|
Nullable включён, `TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать.
|
||||||
- Nullable reference types включены; `Directory.Build.props` включает
|
- Планировщик (`Domain/Programming/Planning`) — **чистая функция**: вход `PlanningInput`, никаких
|
||||||
`TreatWarningsAsErrors=true` — предупреждения анализаторов не игнорировать.
|
запросов и часов внутри. Данные под него собирает `Application/Programming/Planning`.
|
||||||
|
|
||||||
## Домен: роли, пользователи, аутентификация
|
## Инварианты, которые легко нарушить
|
||||||
|
|
||||||
Полноценной доменной модели (каналы/видео) пока нет — см. заметку в начале файла. Текущие
|
- **Роли** динамические, у пользователя ровно одна; системные `admin`/`user` (`IsSystem=true`) не
|
||||||
инварианты:
|
переименовать и не удалить, последнего админа не разжаловать, себя не заблокировать и не удалить.
|
||||||
|
- **Регистрация открытая** (создаётся `user`, токены сразу); понадобится модерация — заводи отдельный
|
||||||
- **Роли** (`AppRole : IdentityRole<Guid>`, `Infrastructure/Identity/`) — динамические,
|
флаг, не переиспользуй `IsBlocked`. Вход по `UserName`, email и SMTP не используются; JWT access +
|
||||||
без квот (в отличие от PnvPanel: тут нет `MaxIpLimit`/`BillingEnabled` — при появлении
|
refresh в httpOnly `tw_refresh_token` с обнаружением повторного использования отозванного.
|
||||||
доменных фич квоты/лимиты добавляются на `AppRole`/`AppUser` по мере необходимости, не заранее).
|
- **Правка сетки эфир не двигает**: команда поднимает ревизию шаблона, ленту пересобирает применение.
|
||||||
У пользователя ровно одна роль. Системные роли `admin`/`user` (`IsSystem=true`) нельзя
|
- **Вещательные сутки** начинаются с `Channel.DayStartTime` (06:00), сетка задаётся во времени
|
||||||
переименовать/удалить — проверяется в `RoleService`. Смену роли (`ChangeUserRoleCommand`)
|
канала (`UtcOffsetMinutes`), в базе всё в UTC.
|
||||||
запрещено делать так, чтобы не осталось ни одного `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`).
|
- Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`), один
|
||||||
Один origin, база API — относительный `/api`.
|
origin, база API — относительный `/api`. Multi-stage Dockerfile: node → dotnet sdk → aspnet.
|
||||||
- Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet
|
Отдельный nginx для статики не вводить — ломает требование единого контейнера.
|
||||||
runtime. Контейнер работает под непривилегированным пользователем образа (`USER $APP_UID`,
|
- Работает под непривилегированным `USER $APP_UID` (uid/gid 1654) **статически**, без
|
||||||
uid/gid 1654) — но **статически**, без gosu/entrypoint-скриптов и подгонки uid на старте: права
|
gosu/entrypoint: права на bind-mount выставляет оператор (`docs/server-storage-setup.md` § 5).
|
||||||
на bind-mount хранилища выставляет оператор на хосте (см. `docs/server-storage-setup.md` § 5).
|
Data Protection не заводим — шифруемых секретов на диске нет. **TLS внешний**: `app` отдаёт HTTP
|
||||||
Data Protection key-ring не заводим — секретов на диске пока нет (JWT-ключ — обычная конфигурация,
|
и доверяет `X-Forwarded-*`.
|
||||||
не шифруемый секрет at-rest); появятся зашифрованные данные (например API-ключи внешних
|
- docker-compose: только `app`. PostgreSQL внешний (`ConnectionStrings__Default` из `.env`), база
|
||||||
сервисов) — добавлять Data Protection по образцу PnvPanel.
|
и пользователь создаются заранее вручную; миграции применяются на старте (`ApplyMigrationsAsync`),
|
||||||
- docker-compose: только `app` — PostgreSQL живёт вне compose (внешний сервер/хост, адрес и
|
прав `CREATEDB` не нужно.
|
||||||
креды — в `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` (валидатор — где есть что
|
- `<Verb><Noun>Command`/`<Get|List><Noun>Query` + `Handler`/`Validator` (где есть что проверить),
|
||||||
проверить). Папки — по фичам (`Auth/Login/`, `Admin/Roles/CreateRole/`, ...).
|
папки по фичам (`Auth/Login/`, `Programming/Templates/Generate/`, …). Один публичный тип на файл =
|
||||||
- DTO — суффикс `Dto`; тела запросов Api-эндпоинтов, не совпадающие с командой 1:1, — `Body`
|
имя файла, кроме `Body` — тела Api-эндпоинтов, не совпадающие с командой 1:1 (`sealed record`
|
||||||
(`sealed record` внизу файла эндпоинта).
|
внизу файла эндпоинта). DTO — суффикс `Dto`.
|
||||||
- Один публичный тип на файл = имя файла (кроме `Body` в файле эндпоинтов).
|
- Ошибки — каталоги `XErrors` (`Error.Validation/NotFound/Conflict/…`), наружу единым
|
||||||
- Ошибки — статические каталоги `XErrors` (`AuthErrors`, `RoleErrors`, `UserErrors`) с
|
`application/problem+json` (`Api/Common/ResultExtensions.cs`). Секреты не логировать.
|
||||||
`Error.Validation/NotFound/Conflict/Unauthorized/Forbidden(...)`.
|
- **Изображения — только через реестр `Image`** (`Domain/Images`, галерея `features/admin/images`):
|
||||||
- Секреты не логировать; логи — Serilog.
|
картинка хранится как `ImageId` и выбирается контролом `ImageGallery`/`GalleryBrowser`, своих
|
||||||
- Ошибки API — единый `application/problem+json` (см. `Api/Common/ResultExtensions.cs`).
|
файловых полей не заводить. Файлы — `images/{id}{ext}`, отдаются по `/api/images/{id}`.
|
||||||
- **Изображения — через общий реестр `Image`** (домен `Domain/Images`, галерея `features/admin/images`).
|
- Фронт: фича = папка в `src/features/admin/<фича>`; тексты только через i18n, ключи в `ru.ts` и `en.ts`.
|
||||||
Любой новый функционал, где загружается или выбирается картинка, должен использовать контрол
|
- Комментарии объясняют **почему**, а не что; по-русски, как в существующем коде.
|
||||||
`ImageGallery`/`GalleryBrowser` (пикер по категориям `ImageCategory`) и хранить ссылку на `ImageId`,
|
|
||||||
а не заводить своё файловое поле. Автоматически полученные картинки (например постер из метадаты)
|
|
||||||
тоже регистрируются как `Image`. Файлы лежат под `images/{id}{ext}`, отдаются по `/api/images/{id}`.
|
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
Backend (из `backend/`):
|
Backend (из `backend/`):
|
||||||
```bash
|
```bash
|
||||||
dotnet build
|
dotnet build
|
||||||
# Форматирование — csharpier, версия закреплена в .config/dotnet-tools.json (один раз: dotnet tool restore).
|
dotnet csharpier format . # только через dotnet (версия из .config/); в CI — csharpier check
|
||||||
# Глобально установленный csharpier может быть другой версии — вызывать только через dotnet:
|
dotnet test tests/TeleWave.Domain.Tests # по одному проекту: MSBuild не берёт несколько
|
||||||
dotnet csharpier format .
|
|
||||||
dotnet csharpier check .
|
|
||||||
# Юнит-тесты (по одному проекту за вызов — MSBuild не принимает несколько):
|
|
||||||
dotnet test tests/TeleWave.Domain.Tests
|
|
||||||
dotnet test tests/TeleWave.Application.Tests
|
dotnet test tests/TeleWave.Application.Tests
|
||||||
# Интеграционные тесты (Testcontainers-Postgres) — нужен запущенный Docker; без него пропускаются:
|
dotnet test tests/TeleWave.Integration.Tests # Testcontainers; без Docker пропускаются
|
||||||
dotnet test tests/TeleWave.Integration.Tests
|
|
||||||
dotnet run --project src/TeleWave.Api
|
dotnet run --project src/TeleWave.Api
|
||||||
dotnet ef migrations add <Name> --project src/TeleWave.Infrastructure --startup-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/`):
|
Frontend (из `frontend/`):
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install && pnpm dev
|
||||||
pnpm dev
|
pnpm lint && pnpm typecheck && pnpm build
|
||||||
pnpm build
|
pnpm format && pnpm format:check # CI падает на неотформатированном — прогоняй после правок
|
||||||
pnpm lint && pnpm typecheck
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Инфраструктура:
|
Инфраструктура: `docker compose up -d --build` (только api; postgres внешний). Окружение — Windows,
|
||||||
```bash
|
оболочка **PowerShell**; для POSIX-скриптов есть Bash-инструмент.
|
||||||
docker compose up -d --build # только api; postgres — внешний, см. ConnectionStrings__Default
|
|
||||||
```
|
|
||||||
|
|
||||||
> Окружение: Windows, основная оболочка — **PowerShell**. Для POSIX-скриптов есть Bash-инструмент.
|
|
||||||
|
|
||||||
## Рабочие принципы
|
## Рабочие принципы
|
||||||
|
|
||||||
- Базовый вертикальный срез (каналы, планировщик, раздача HLS, обработка медиа) уже реализован —
|
- Базовый срез реализован — правь по месту. Новые крупные направления с непринятыми решениями
|
||||||
правь его по месту. Но новые крупные направления с непринятыми архитектурными решениями (напр.
|
(многоэкземплярность, транскод-профили, подсистемы) — после сверки; неоднозначность — вопрос.
|
||||||
многоэкземплярное развёртывание, транскод-профили, новые доменные подсистемы) начинай только после
|
- Меняешь поведение подсистемы — обнови её спеку в `docs/` тем же заходом.
|
||||||
сверки с пользователем. При неоднозначности — вопрос пользователю, не предположение.
|
- Соблюдай границы слоёв — главный инвариант проекта.
|
||||||
- Соблюдай границы слоёв — главный инвариант проекта, как и в PnvPanel.
|
- Правь файлы через Edit/Write: пользователь видит дифф, а harness не даст перезаписать файл вслепую.
|
||||||
- Не коммить и не пуши без явной просьбы.
|
Скриптом на python — только при крайней необходимости (массовая механическая замена), и сказать явно.
|
||||||
- Отвечай пользователю на русском.
|
- Не коммить и не пуши без явной просьбы; отвечай пользователю на русском.
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,9 @@
|
|||||||
FROM node:22-alpine AS frontend
|
FROM node:22-alpine AS frontend
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
# pnpm-workspace.yaml здесь не про воркспейсы: в pnpm 11 там живут overrides, и без него
|
||||||
|
# --frozen-lockfile падает на несовпадении конфигурации с локом.
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
|
||||||
# --ignore-scripts: ни один пакет фронта не требует postinstall, поэтому lifecycle-скрипты зависимостей
|
# --ignore-scripts: ни один пакет фронта не требует postinstall, поэтому lifecycle-скрипты зависимостей
|
||||||
# в сборке не выполняются — компрометация любого пакета в дереве не даёт исполнения кода на этапе install.
|
# в сборке не выполняются — компрометация любого пакета в дереве не даёт исполнения кода на этапе install.
|
||||||
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile --ignore-scripts
|
RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
# TeleWave
|
# TeleWave
|
||||||
|
|
||||||
[](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml)
|
[](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml)
|
||||||
[](https://gitea.hsrv.site/mrleo1nid/TeleWave/actions?workflow=ci.yml)
|
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||||
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=mrleo1nid_telewave)
|
||||||
|
|
||||||
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
|
**TeleWave** — сервис онлайн-каналов: пользователи смотрят сетку каналов, видео отдаётся из
|
||||||
хранилища на сервере, админ управляет каналами и пользователями.
|
хранилища на сервере, админ управляет каналами и пользователями.
|
||||||
|
|||||||
+112
-110
@@ -1,35 +1,88 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Broadcast;
|
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Infrastructure.Identity;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
/// <summary>Эндпоинты ТВ-заставок канала: блоки (стиль/аудио/фон), подблоки и рендер превью.</summary>
|
/// <summary>
|
||||||
public static partial class ChannelEndpoints
|
/// Блоки ТВ-заставок: оформление, звук, подблоки с текстом и рендер превью. Блоки общие для всех
|
||||||
|
/// каналов, поэтому и раздел свой, не канальный — канал только ссылается на них врезками стыков.
|
||||||
|
/// </summary>
|
||||||
|
public static class BumperEndpoints
|
||||||
{
|
{
|
||||||
private static async Task<IResult> AddBumperTemplate(
|
public static IEndpointRouteBuilder MapBumperEndpoints(this IEndpointRouteBuilder app)
|
||||||
Guid id,
|
{
|
||||||
AddBumperTemplateBody body,
|
var admin = app.MapGroup("/api/admin/bumpers")
|
||||||
|
.WithTags("Admin.Bumpers")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("", List).Produces<IReadOnlyList<BumperTemplateDto>>();
|
||||||
|
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
|
admin.MapPut("/{templateId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapDelete("/{templateId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
admin
|
||||||
|
.MapPut("/{templateId:guid}/audio", UploadAudio)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapDelete("/{templateId:guid}/audio", ClearAudio)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapPut("/{templateId:guid}/background", SetBackground)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapDelete("/{templateId:guid}/background", ClearBackground)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
admin
|
||||||
|
.MapPost("/{templateId:guid}/variants", AddVariant)
|
||||||
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
|
admin
|
||||||
|
.MapPut("/{templateId:guid}/variants/{variantId:guid}", UpdateVariant)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin
|
||||||
|
.MapDelete("/{templateId:guid}/variants/{variantId:guid}", RemoveVariant)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
admin
|
||||||
|
.MapPost("/{templateId:guid}/preview", RenderPreview)
|
||||||
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/index.m3u8", PreviewPlaylist);
|
||||||
|
admin.MapGet("/{templateId:guid}/preview/{variantId:guid}/{file}", PreviewSegment);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListBumperTemplatesQuery(), cancellationToken);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Create(
|
||||||
|
BumperNameBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new AddBumperTemplateCommand(id, body.Name),
|
new CreateBumperTemplateCommand(body.Name),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
? Results.Created(
|
||||||
|
$"/api/admin/bumpers/{result.Value}",
|
||||||
|
new CreatedIdResponse(result.Value)
|
||||||
|
)
|
||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateBumperTemplate(
|
private static async Task<IResult> Update(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
UpdateBumperTemplateBody body,
|
UpdateBumperTemplateBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
@@ -38,35 +91,37 @@ public static partial class ChannelEndpoints
|
|||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateBumperTemplateCommand(
|
new UpdateBumperTemplateCommand(
|
||||||
id,
|
|
||||||
templateId,
|
templateId,
|
||||||
body.Name,
|
new BumperStyle(
|
||||||
body.BackgroundColor,
|
body.Name,
|
||||||
body.BackgroundColor2,
|
body.Font,
|
||||||
body.AccentColor,
|
body.BackgroundColor,
|
||||||
body.TextColor
|
body.BackgroundColor2,
|
||||||
|
body.AccentColor,
|
||||||
|
body.TextColor
|
||||||
|
)
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RemoveBumperTemplate(
|
private static async Task<IResult> Delete(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new RemoveBumperTemplateCommand(id, templateId),
|
new DeleteBumperTemplateCommand(templateId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UploadTemplateAudio(
|
private static async Task<IResult> UploadAudio(
|
||||||
[AsParameters] BumperAudioUpload upload,
|
Guid templateId,
|
||||||
|
string fileName,
|
||||||
HttpRequest request,
|
HttpRequest request,
|
||||||
IBumperTemplateStorage storage,
|
IBumperTemplateStorage storage,
|
||||||
IAudioProbe probe,
|
IAudioProbe probe,
|
||||||
@@ -74,124 +129,101 @@ public static partial class ChannelEndpoints
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (
|
if (ResolveExtension(fileName, request) is not { } ext)
|
||||||
ResolveBumperExtension(upload.FileName, request, BumperFiles.AudioExtensions)
|
return BumperErrors.InvalidFile.ToProblem();
|
||||||
is not { } ext
|
|
||||||
)
|
|
||||||
return ChannelErrors.InvalidBumperFile.ToProblem();
|
|
||||||
|
|
||||||
await storage.SaveAudioAsync(upload.TemplateId, ext, request.Body, cancellationToken);
|
await storage.SaveAudioAsync(templateId, ext, request.Body, cancellationToken);
|
||||||
|
|
||||||
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
|
// Длина заставки идёт по длине звука — замеряем ffprobe (при неудаче 0 → дефолтная длина).
|
||||||
var path = storage.AudioPath(upload.TemplateId, ext);
|
var path = storage.AudioPath(templateId, ext);
|
||||||
var duration = path is null
|
var duration = path is null
|
||||||
? null
|
? null
|
||||||
: await probe.TryGetDurationAsync(path, cancellationToken);
|
: await probe.TryGetDurationAsync(path, cancellationToken);
|
||||||
|
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new SetBumperTemplateAudioCommand(
|
new SetBumperTemplateAudioCommand(templateId, ext, duration?.TotalSeconds ?? 0),
|
||||||
upload.Id,
|
|
||||||
upload.TemplateId,
|
|
||||||
ext,
|
|
||||||
duration?.TotalSeconds ?? 0
|
|
||||||
),
|
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
await storage.DeleteAudioAsync(upload.TemplateId, cancellationToken);
|
await storage.DeleteAudioAsync(templateId, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ClearTemplateAudio(
|
private static async Task<IResult> ClearAudio(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ClearBumperTemplateAudioCommand(id, templateId),
|
new ClearBumperTemplateAudioCommand(templateId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> SetTemplateBackground(
|
private static async Task<IResult> SetBackground(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
SetBumperTemplateBackgroundBody body,
|
SetBumperBackgroundBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new SetBumperTemplateBackgroundCommand(id, templateId, body.ImageId),
|
new SetBumperTemplateBackgroundCommand(templateId, body.ImageId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ClearTemplateBackground(
|
private static async Task<IResult> ClearBackground(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ClearBumperTemplateBackgroundCommand(id, templateId),
|
new ClearBumperTemplateBackgroundCommand(templateId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> AddBumperVariant(
|
private static async Task<IResult> AddVariant(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
AddBumperVariantBody body,
|
BumperNameBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new AddBumperTextVariantCommand(id, templateId, body.Name),
|
new AddBumperVariantCommand(templateId, body.Name),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created($"/api/admin/channels/{id}", new CreatedIdResponse(result.Value))
|
? Results.Created(
|
||||||
|
$"/api/admin/bumpers/{templateId}",
|
||||||
|
new CreatedIdResponse(result.Value)
|
||||||
|
)
|
||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateBumperVariant(
|
private static async Task<IResult> UpdateVariant(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
Guid variantId,
|
Guid variantId,
|
||||||
UpdateBumperVariantBody body,
|
BumperVariantInput input,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateBumperTextVariantCommand(
|
new UpdateBumperVariantCommand(templateId, variantId, input),
|
||||||
id,
|
|
||||||
templateId,
|
|
||||||
variantId,
|
|
||||||
body.Name,
|
|
||||||
body.Kind,
|
|
||||||
body.NowLabel,
|
|
||||||
body.NextLabel,
|
|
||||||
body.Line1,
|
|
||||||
body.Line2,
|
|
||||||
body.Trigger,
|
|
||||||
body.Weight
|
|
||||||
),
|
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RemoveBumperVariant(
|
private static async Task<IResult> RemoveVariant(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
Guid variantId,
|
Guid variantId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
@@ -199,7 +231,7 @@ public static partial class ChannelEndpoints
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new RemoveBumperTextVariantCommand(id, templateId, variantId),
|
new RemoveBumperVariantCommand(templateId, variantId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -207,33 +239,27 @@ public static partial class ChannelEndpoints
|
|||||||
|
|
||||||
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
|
/// <summary>Синхронно рендерит пример заставки блока (несколько секунд ffmpeg).</summary>
|
||||||
private static async Task<IResult> RenderPreview(
|
private static async Task<IResult> RenderPreview(
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
|
Guid? channelId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new RenderBumperPreviewCommand(id, templateId),
|
new RenderBumperPreviewCommand(templateId, channelId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
return result.IsSuccess ? Results.NoContent() : result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
/// <summary>Плейлист превью подблока: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||||
private static IResult PreviewPlaylist(
|
private static IResult PreviewPlaylist(Guid templateId, Guid variantId, MediaPathResolver paths)
|
||||||
Guid id,
|
|
||||||
Guid templateId,
|
|
||||||
Guid variantId,
|
|
||||||
MediaPathResolver paths
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var previewId = BumperPreview.AssetId(variantId);
|
var previewId = BumperPreview.AssetId(variantId);
|
||||||
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
|
if (SegmentFiles.TryResolveExisting(paths, previewId, "index.m3u8") is not { } indexPath)
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
var baseUrl =
|
var baseUrl = $"/api/admin/bumpers/{templateId}/preview/{variantId}/";
|
||||||
$"/api/admin/channels/{id}/bumper/templates/{templateId}/preview/{variantId}/";
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
foreach (var line in File.ReadLines(indexPath))
|
foreach (var line in File.ReadLines(indexPath))
|
||||||
{
|
{
|
||||||
@@ -249,9 +275,8 @@ public static partial class ChannelEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сегмент превью. Канал и блок в маршруте есть, но хендлеру не нужны: каталог превью
|
/// Сегмент превью. Блок в маршруте есть, но хендлеру не нужен: каталог превью адресуется
|
||||||
/// адресуется подблоком (см. BumperPreview.AssetId), поэтому в сигнатуре их нет — незаявленные
|
/// подблоком (см. <see cref="BumperPreview.AssetId"/>) — незаявленные параметры не связываются.
|
||||||
/// параметры маршрута просто не связываются.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
|
private static IResult PreviewSegment(Guid variantId, string file, MediaPathResolver paths)
|
||||||
{
|
{
|
||||||
@@ -267,50 +292,27 @@ public static partial class ChannelEndpoints
|
|||||||
|
|
||||||
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
|
/// <summary>Проверяет расширение файла (по allowlist) и размер (Content-Length). Возвращает
|
||||||
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
|
/// нормализованное расширение (с точкой, нижний регистр) или null при отказе.</summary>
|
||||||
private static string? ResolveBumperExtension(
|
private static string? ResolveExtension(string fileName, HttpRequest request)
|
||||||
string fileName,
|
|
||||||
HttpRequest request,
|
|
||||||
IReadOnlySet<string> allowedExtensions
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
|
if (request.ContentLength is > BumperFiles.MaxBytes or 0 or null)
|
||||||
return null;
|
return null;
|
||||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
return allowedExtensions.Contains(ext) ? ext : null;
|
return BumperFiles.AudioExtensions.Contains(ext) ? ext : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AddBumperTemplateBody(string Name);
|
public sealed record BumperNameBody(string Name);
|
||||||
|
|
||||||
public sealed record UpdateBumperTemplateBody(
|
public sealed record UpdateBumperTemplateBody(
|
||||||
string Name,
|
string Name,
|
||||||
|
BumperFont Font,
|
||||||
string BackgroundColor,
|
string BackgroundColor,
|
||||||
string BackgroundColor2,
|
string BackgroundColor2,
|
||||||
string AccentColor,
|
string AccentColor,
|
||||||
string TextColor
|
string TextColor
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record SetBumperTemplateBackgroundBody(Guid ImageId);
|
public sealed record SetBumperBackgroundBody(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>
|
/// <summary>Ограничения на загружаемый звук блока заставки (фон-картинка — через общий реестр).</summary>
|
||||||
internal static class BumperFiles
|
internal static class BumperFiles
|
||||||
@@ -1,247 +1,210 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Broadcast.CreateChannel;
|
using TeleWave.Application.Broadcast.CreateChannel;
|
||||||
using TeleWave.Application.Broadcast.GetChannel;
|
using TeleWave.Application.Broadcast.GetChannel;
|
||||||
using TeleWave.Application.Broadcast.GetSchedule;
|
using TeleWave.Application.Broadcast.GetSchedule;
|
||||||
using TeleWave.Application.Broadcast.ListChannels;
|
using TeleWave.Application.Broadcast.ListChannels;
|
||||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
using TeleWave.Application.Broadcast.UpdateChannelTime;
|
using TeleWave.Application.Broadcast.UpdateChannelTime;
|
||||||
using TeleWave.Application.Broadcast.UpdateViewerSettings;
|
using TeleWave.Application.Broadcast.UpdateViewerSettings;
|
||||||
using TeleWave.Application.Programming.Planning.Trace;
|
using TeleWave.Application.Programming.Planning.DebugExport;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Application.Programming.Planning.Trace;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Infrastructure.Identity;
|
||||||
namespace TeleWave.Api.Endpoints;
|
|
||||||
|
namespace TeleWave.Api.Endpoints;
|
||||||
/// <summary>
|
|
||||||
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
|
/// <summary>
|
||||||
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
|
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки
|
||||||
/// (<c>TemplateEndpoints</c>).
|
/// и стыки общие для всех каналов и живут своими разделами (<c>BumperEndpoints</c>,
|
||||||
/// </summary>
|
/// <c>JunctionEndpoints</c>); что и когда идёт в эфире, задаёт шаблон сетки (<c>TemplateEndpoints</c>).
|
||||||
public static partial class ChannelEndpoints
|
/// </summary>
|
||||||
{
|
public static class ChannelEndpoints
|
||||||
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
|
{
|
||||||
{
|
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
|
||||||
var admin = app.MapGroup("/api/admin/channels")
|
{
|
||||||
.WithTags("Admin.Channels")
|
var admin = app.MapGroup("/api/admin/channels")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.WithTags("Admin.Channels")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
|
||||||
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
|
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
|
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
|
||||||
admin
|
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
|
||||||
.MapPut("/{id:guid}/settings", UpdateSettings)
|
admin
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.MapPut("/{id:guid}/settings", UpdateSettings)
|
||||||
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
|
||||||
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
|
admin
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.MapGet("/{id:guid}/schedule", GetSchedule)
|
||||||
admin
|
.Produces<IReadOnlyList<ScheduleEntryDto>>();
|
||||||
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
|
admin
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
|
||||||
admin
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
|
||||||
admin
|
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
|
||||||
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
// Отладочный дамп: вход планировщика и результат одним архивом.
|
||||||
admin
|
admin.MapPost("/{id:guid}/debug-export", ExportDebug).Produces(StatusCodes.Status200OK);
|
||||||
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
return app;
|
||||||
admin
|
}
|
||||||
.MapPut(
|
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/background",
|
/// <summary>
|
||||||
SetTemplateBackground
|
/// Отдаёт архив со снимком канала. POST, а не GET: команда оставляет копию дампа на сервере,
|
||||||
)
|
/// и кэшировать такой ответ никому не следует.
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
/// </summary>
|
||||||
admin
|
private static async Task<IResult> ExportDebug(
|
||||||
.MapDelete(
|
Guid id,
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/background",
|
ISender sender,
|
||||||
ClearTemplateBackground
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
{
|
||||||
|
var result = await sender.Send(new ExportChannelDebugCommand(id), cancellationToken);
|
||||||
admin
|
if (!result.IsSuccess)
|
||||||
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
|
return result.ToHttpResult();
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin.MapGet(
|
return Results.File(result.Value.Content, "application/zip", result.Value.FileName);
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
|
}
|
||||||
PreviewPlaylist
|
|
||||||
);
|
private static async Task<IResult> UpdateViewerSettings(
|
||||||
admin.MapGet(
|
Guid id,
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
|
UpdateViewerSettingsBody body,
|
||||||
PreviewSegment
|
ISender sender,
|
||||||
);
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
admin
|
{
|
||||||
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
|
var result = await sender.Send(
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
new UpdateViewerSettingsCommand(
|
||||||
admin
|
id,
|
||||||
.MapPut(
|
body.LogoImageId,
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
|
body.LogoCorner,
|
||||||
UpdateBumperVariant
|
body.LogoOpacity,
|
||||||
)
|
body.ShowClock,
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
body.AnalogFilterStrength
|
||||||
admin
|
),
|
||||||
.MapDelete(
|
cancellationToken
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
|
);
|
||||||
RemoveBumperVariant
|
return result.ToHttpResult();
|
||||||
)
|
}
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
|
private static async Task<IResult> GetEntryTrace(
|
||||||
admin
|
Guid entryId,
|
||||||
.MapGet("/{id:guid}/schedule", GetSchedule)
|
ISender sender,
|
||||||
.Produces<IReadOnlyList<ScheduleEntryDto>>();
|
CancellationToken cancellationToken
|
||||||
admin
|
)
|
||||||
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
|
{
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
|
}
|
||||||
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
|
|
||||||
|
private static async Task<IResult> CreateChannel(
|
||||||
return app;
|
CreateChannelCommand command,
|
||||||
}
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
private static async Task<IResult> UpdateViewerSettings(
|
)
|
||||||
Guid id,
|
{
|
||||||
UpdateViewerSettingsBody body,
|
var result = await sender.Send(command, cancellationToken);
|
||||||
ISender sender,
|
return result.IsSuccess
|
||||||
CancellationToken cancellationToken
|
? Results.Created(
|
||||||
)
|
$"/api/admin/channels/{result.Value}",
|
||||||
{
|
new CreatedIdResponse(result.Value)
|
||||||
var result = await sender.Send(
|
)
|
||||||
new UpdateViewerSettingsCommand(
|
: result.ToHttpResult();
|
||||||
id,
|
}
|
||||||
body.LogoImageId,
|
|
||||||
body.LogoCorner,
|
private static async Task<IResult> ListChannels(
|
||||||
body.LogoOpacity,
|
ISender sender,
|
||||||
body.ShowClock,
|
CancellationToken cancellationToken
|
||||||
body.AnalogFilterStrength
|
)
|
||||||
),
|
{
|
||||||
cancellationToken
|
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
|
||||||
);
|
return Results.Ok(result);
|
||||||
return result.ToHttpResult();
|
}
|
||||||
}
|
|
||||||
|
private static async Task<IResult> GetChannel(
|
||||||
private static async Task<IResult> GetEntryTrace(
|
Guid id,
|
||||||
Guid entryId,
|
ISender sender,
|
||||||
ISender sender,
|
CancellationToken cancellationToken
|
||||||
CancellationToken cancellationToken
|
)
|
||||||
)
|
{
|
||||||
{
|
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
|
||||||
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
|
return result.ToHttpResult();
|
||||||
return result.ToHttpResult();
|
}
|
||||||
}
|
|
||||||
|
private static async Task<IResult> UpdateTime(
|
||||||
private static async Task<IResult> CreateChannel(
|
Guid id,
|
||||||
CreateChannelCommand command,
|
UpdateChannelTimeBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(
|
||||||
return result.IsSuccess
|
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
|
||||||
? Results.Created(
|
cancellationToken
|
||||||
$"/api/admin/channels/{result.Value}",
|
);
|
||||||
new CreatedIdResponse(result.Value)
|
return result.ToHttpResult();
|
||||||
)
|
}
|
||||||
: result.ToHttpResult();
|
|
||||||
}
|
private static async Task<IResult> UpdateSettings(
|
||||||
|
Guid id,
|
||||||
private static async Task<IResult> ListChannels(
|
UpdateChannelSettingsBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
|
var result = await sender.Send(
|
||||||
return Results.Ok(result);
|
new UpdateChannelSettingsCommand(
|
||||||
}
|
id,
|
||||||
|
body.Name,
|
||||||
private static async Task<IResult> GetChannel(
|
body.IsEnabled,
|
||||||
Guid id,
|
body.FillerAssetId,
|
||||||
ISender sender,
|
body.IconImageId
|
||||||
CancellationToken cancellationToken
|
),
|
||||||
)
|
cancellationToken
|
||||||
{
|
);
|
||||||
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
|
return result.ToHttpResult();
|
||||||
return result.ToHttpResult();
|
}
|
||||||
}
|
|
||||||
|
private static async Task<IResult> GetSchedule(
|
||||||
private static async Task<IResult> UpdateTime(
|
Guid id,
|
||||||
Guid id,
|
DateTimeOffset? from,
|
||||||
UpdateChannelTimeBody body,
|
DateTimeOffset? to,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
||||||
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
|
var toUtc = to ?? fromUtc.AddDays(1);
|
||||||
cancellationToken
|
var result = await sender.Send(
|
||||||
);
|
new GetChannelScheduleQuery(id, fromUtc, toUtc),
|
||||||
return result.ToHttpResult();
|
cancellationToken
|
||||||
}
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
private static async Task<IResult> UpdateSettings(
|
}
|
||||||
Guid id,
|
}
|
||||||
UpdateChannelSettingsBody body,
|
|
||||||
ISender sender,
|
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
|
||||||
CancellationToken cancellationToken
|
public sealed record UpdateChannelTimeBody(
|
||||||
)
|
int? Number,
|
||||||
{
|
int UtcOffsetMinutes,
|
||||||
var result = await sender.Send(
|
TimeOnly DayStartTime
|
||||||
new UpdateChannelSettingsCommand(
|
);
|
||||||
id,
|
|
||||||
body.Name,
|
public sealed record UpdateChannelSettingsBody(
|
||||||
body.IsEnabled,
|
string Name,
|
||||||
body.BumpersEnabled,
|
bool IsEnabled,
|
||||||
body.Bumper,
|
Guid? FillerAssetId,
|
||||||
body.FillerAssetId
|
Guid? IconImageId
|
||||||
),
|
);
|
||||||
cancellationToken
|
|
||||||
);
|
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
|
||||||
return result.ToHttpResult();
|
public sealed record UpdateViewerSettingsBody(
|
||||||
}
|
Guid? LogoImageId,
|
||||||
|
LogoCorner LogoCorner,
|
||||||
private static async Task<IResult> GetSchedule(
|
double LogoOpacity,
|
||||||
Guid id,
|
bool ShowClock,
|
||||||
DateTimeOffset? from,
|
double AnalogFilterStrength
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ using LiteCqrs;
|
|||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Library.Collections;
|
using TeleWave.Application.Library.Collections;
|
||||||
using TeleWave.Application.Library.Collections.AddCollectionShow;
|
using TeleWave.Application.Library.Collections.AddCollectionShow;
|
||||||
|
using TeleWave.Application.Library.Collections.AddShowsToCollection;
|
||||||
using TeleWave.Application.Library.Collections.CreateCollection;
|
using TeleWave.Application.Library.Collections.CreateCollection;
|
||||||
using TeleWave.Application.Library.Collections.DeleteCollection;
|
using TeleWave.Application.Library.Collections.DeleteCollection;
|
||||||
|
using TeleWave.Application.Library.Collections.FillCollectionPosters;
|
||||||
using TeleWave.Application.Library.Collections.GetCollection;
|
using TeleWave.Application.Library.Collections.GetCollection;
|
||||||
using TeleWave.Application.Library.Collections.ListCollections;
|
using TeleWave.Application.Library.Collections.ListCollections;
|
||||||
using TeleWave.Application.Library.Collections.RemoveCollectionShow;
|
using TeleWave.Application.Library.Collections.RemoveCollectionShow;
|
||||||
using TeleWave.Application.Library.Collections.ReorderCollection;
|
using TeleWave.Application.Library.Collections.ReorderCollection;
|
||||||
using TeleWave.Application.Library.Collections.SetCollectionPoster;
|
using TeleWave.Application.Library.Collections.SetCollectionPoster;
|
||||||
|
using TeleWave.Application.Library.Collections.Suggest;
|
||||||
using TeleWave.Application.Library.Collections.UpdateCollection;
|
using TeleWave.Application.Library.Collections.UpdateCollection;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
@@ -36,9 +39,89 @@ public static class CollectionEndpoints
|
|||||||
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/{id:guid}/poster-image", SetPoster).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
// Сборка франшиз: массовое добавление отмеченных шоу и предложения по библиотеке.
|
||||||
|
admin.MapPost("/bulk/shows", AddShows).Produces<AddShowsToCollectionResultDto>();
|
||||||
|
// Постеры по частям: без списка — всем коллекциям, у которых постера ещё нет.
|
||||||
|
admin.MapPost("/bulk/posters", FillPosters).Produces<int>();
|
||||||
|
admin
|
||||||
|
.MapGet("/suggestions", Suggestions)
|
||||||
|
.Produces<IReadOnlyList<CollectionSuggestionDto>>();
|
||||||
|
admin
|
||||||
|
.MapPost("/suggestions", CreateFromSuggestion)
|
||||||
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
|
// Дополнение уже собранной франшизы: коллекция определяется тем же ключом предложения.
|
||||||
|
admin.MapPost("/suggestions/add", AddFromSuggestion).Produces<int>();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> FillPosters(
|
||||||
|
FillCollectionPostersBody? body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new FillCollectionPostersCommand(body?.CollectionIds),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AddShows(
|
||||||
|
AddShowsToCollectionBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new AddShowsToCollectionCommand(
|
||||||
|
body.ShowIds,
|
||||||
|
body.CollectionId,
|
||||||
|
body.NewCollectionName
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Suggestions(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new SuggestCollectionsQuery(), cancellationToken);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateFromSuggestion(
|
||||||
|
CreateCollectionFromSuggestionBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new CreateCollectionFromSuggestionCommand(body.Key),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Created(
|
||||||
|
$"/api/admin/collections/{result.Value}",
|
||||||
|
new CreatedIdResponse(result.Value)
|
||||||
|
)
|
||||||
|
: result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AddFromSuggestion(
|
||||||
|
CreateCollectionFromSuggestionBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new AddSuggestedShowsCommand(body.Key), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListCollections(
|
private static async Task<IResult> ListCollections(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
@@ -161,3 +244,14 @@ public sealed record CollectionShowBody(Guid ShowId);
|
|||||||
public sealed record ReorderCollectionBody(IReadOnlyList<Guid> ShowIdsInOrder);
|
public sealed record ReorderCollectionBody(IReadOnlyList<Guid> ShowIdsInOrder);
|
||||||
|
|
||||||
public sealed record CollectionPosterBody(Guid? ImageId);
|
public sealed record CollectionPosterBody(Guid? ImageId);
|
||||||
|
|
||||||
|
/// <summary>Куда складывать: существующая коллекция либо новая по имени.</summary>
|
||||||
|
public sealed record AddShowsToCollectionBody(
|
||||||
|
IReadOnlyList<Guid> ShowIds,
|
||||||
|
Guid? CollectionId = null,
|
||||||
|
string? NewCollectionName = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record CreateCollectionFromSuggestionBody(string Key);
|
||||||
|
|
||||||
|
public sealed record FillCollectionPostersBody(IReadOnlyList<Guid>? CollectionIds);
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TeleWave.Application.Diagnostics;
|
||||||
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Админская диагностика. Главное здесь — картина прокси-форвардинга: заголовки, которым доверяет
|
||||||
|
/// приложение, и итоговая схема/хост, из которых строятся абсолютные ссылки (M3U/EPG). Именно тут
|
||||||
|
/// видно, почему ссылки уходят по http вместо https: X-Forwarded-Proto пришёл, но сосед не доверен.
|
||||||
|
/// Плюс живое состояние инфраструктуры (БД, том хранилища, ffmpeg) — через системный запрос.
|
||||||
|
/// </summary>
|
||||||
|
public static class DiagnosticsEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapDiagnosticsEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/admin/diagnostics", Get)
|
||||||
|
.WithTags("Admin.Diagnostics")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
|
||||||
|
.Produces<DiagnosticsResponse>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Get(
|
||||||
|
HttpContext http,
|
||||||
|
IOptions<ForwardedHeadersOptions> forwardedOptions,
|
||||||
|
IHostEnvironment env,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var system = await sender.Send(new GetSystemDiagnosticsQuery(), cancellationToken);
|
||||||
|
var host = BuildHost(env);
|
||||||
|
var forwarding = BuildForwarding(http, forwardedOptions.Value);
|
||||||
|
|
||||||
|
return Results.Ok(new DiagnosticsResponse(host, forwarding, system));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HostDiagnosticsDto BuildHost(IHostEnvironment env)
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetEntryAssembly();
|
||||||
|
var version =
|
||||||
|
assembly
|
||||||
|
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||||
|
?.InformationalVersion
|
||||||
|
?? assembly?.GetName().Version?.ToString();
|
||||||
|
|
||||||
|
return new HostDiagnosticsDto(
|
||||||
|
version,
|
||||||
|
env.EnvironmentName,
|
||||||
|
RuntimeInformation.FrameworkDescription,
|
||||||
|
RuntimeInformation.OSDescription,
|
||||||
|
DateTimeOffset.UtcNow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ForwardingDiagnosticsDto BuildForwarding(
|
||||||
|
HttpContext http,
|
||||||
|
ForwardedHeadersOptions options
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var request = http.Request;
|
||||||
|
var scheme = request.Scheme;
|
||||||
|
var host = request.Host.Value ?? string.Empty;
|
||||||
|
var isHttps = string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var remoteIp = http.Connection.RemoteIpAddress?.ToString();
|
||||||
|
|
||||||
|
var forwardedProto = Header(request, "X-Forwarded-Proto");
|
||||||
|
// X-Original-* заголовки ставит сам ForwardedHeaders-middleware, когда доверяет соседу и
|
||||||
|
// применяет форвардинг — по их наличию отличаем «применено» от «отброшено как недоверенное».
|
||||||
|
var originalProto = Header(request, "X-Original-Proto");
|
||||||
|
var originalRemoteIp = Header(request, "X-Original-For");
|
||||||
|
var applied = originalProto is not null || originalRemoteIp is not null;
|
||||||
|
|
||||||
|
// Что прокси реально прислал (до возможного применения), и вывод с готовой подсказкой.
|
||||||
|
var sentProto = originalProto ?? forwardedProto;
|
||||||
|
string verdict;
|
||||||
|
string? suggestedTrustedIp = null;
|
||||||
|
if (isHttps)
|
||||||
|
verdict = "ok";
|
||||||
|
else if (string.Equals(sentProto, "https", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Заголовок пришёл, но схема осталась http → непосредственный сосед не в доверенных.
|
||||||
|
verdict = "proxyNotTrusted";
|
||||||
|
suggestedTrustedIp = remoteIp;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
verdict = "protoHeaderMissing";
|
||||||
|
|
||||||
|
return new ForwardingDiagnosticsDto(
|
||||||
|
scheme,
|
||||||
|
host,
|
||||||
|
$"{scheme}://{host}",
|
||||||
|
isHttps,
|
||||||
|
remoteIp,
|
||||||
|
forwardedProto,
|
||||||
|
Header(request, "X-Forwarded-For"),
|
||||||
|
Header(request, "X-Forwarded-Host"),
|
||||||
|
Header(request, "X-Real-IP"),
|
||||||
|
originalProto,
|
||||||
|
originalRemoteIp,
|
||||||
|
applied,
|
||||||
|
options.ForwardLimit,
|
||||||
|
options.ForwardedHeaders.ToString(),
|
||||||
|
[.. options.KnownProxies.Select(ip => ip.ToString())],
|
||||||
|
[.. options.KnownIPNetworks.Select(net => net.ToString())],
|
||||||
|
verdict,
|
||||||
|
suggestedTrustedIp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Header(HttpRequest request, string name) =>
|
||||||
|
request.Headers.TryGetValue(name, out var value) && !string.IsNullOrEmpty(value)
|
||||||
|
? value.ToString()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ответ диагностики: процесс/рантайм, картина форвардинга и живое состояние инфраструктуры.</summary>
|
||||||
|
public sealed record DiagnosticsResponse(
|
||||||
|
HostDiagnosticsDto Host,
|
||||||
|
ForwardingDiagnosticsDto Forwarding,
|
||||||
|
SystemDiagnosticsDto System
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record HostDiagnosticsDto(
|
||||||
|
string? AppVersion,
|
||||||
|
string Environment,
|
||||||
|
string Framework,
|
||||||
|
string Os,
|
||||||
|
DateTimeOffset ServerTimeUtc
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Картина X-Forwarded-*. <c>Verdict</c>: <c>ok</c> — схема https; <c>proxyNotTrusted</c> — прокси
|
||||||
|
/// прислал https, но <c>Applied=false</c>, и <c>SuggestedTrustedIp</c> нужно внести в KnownProxies;
|
||||||
|
/// <c>protoHeaderMissing</c> — прокси вообще не шлёт X-Forwarded-Proto.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ForwardingDiagnosticsDto(
|
||||||
|
string Scheme,
|
||||||
|
string Host,
|
||||||
|
string Origin,
|
||||||
|
bool IsHttps,
|
||||||
|
string? RemoteIp,
|
||||||
|
string? XForwardedProto,
|
||||||
|
string? XForwardedFor,
|
||||||
|
string? XForwardedHost,
|
||||||
|
string? XRealIp,
|
||||||
|
string? OriginalProto,
|
||||||
|
string? OriginalRemoteIp,
|
||||||
|
bool Applied,
|
||||||
|
int? ForwardLimit,
|
||||||
|
string ForwardedHeaders,
|
||||||
|
IReadOnlyList<string> KnownProxies,
|
||||||
|
IReadOnlyList<string> KnownNetworks,
|
||||||
|
string Verdict,
|
||||||
|
string? SuggestedTrustedIp
|
||||||
|
);
|
||||||
@@ -4,13 +4,16 @@ using TeleWave.Application.Programming.Groups;
|
|||||||
using TeleWave.Application.Programming.Groups.AddGroupElements;
|
using TeleWave.Application.Programming.Groups.AddGroupElements;
|
||||||
using TeleWave.Application.Programming.Groups.CreateGroup;
|
using TeleWave.Application.Programming.Groups.CreateGroup;
|
||||||
using TeleWave.Application.Programming.Groups.DeleteGroup;
|
using TeleWave.Application.Programming.Groups.DeleteGroup;
|
||||||
|
using TeleWave.Application.Programming.Groups.ExcludeGroupElement;
|
||||||
using TeleWave.Application.Programming.Groups.FindGroupCandidates;
|
using TeleWave.Application.Programming.Groups.FindGroupCandidates;
|
||||||
using TeleWave.Application.Programming.Groups.GetGroup;
|
using TeleWave.Application.Programming.Groups.GetGroup;
|
||||||
using TeleWave.Application.Programming.Groups.ListGroups;
|
using TeleWave.Application.Programming.Groups.ListGroups;
|
||||||
using TeleWave.Application.Programming.Groups.RemoveGroupItem;
|
using TeleWave.Application.Programming.Groups.RemoveGroupItem;
|
||||||
using TeleWave.Application.Programming.Groups.ReorderGroup;
|
using TeleWave.Application.Programming.Groups.ReorderGroup;
|
||||||
using TeleWave.Application.Programming.Groups.SetGroupItemWeight;
|
using TeleWave.Application.Programming.Groups.SetGroupItemWeight;
|
||||||
|
using TeleWave.Application.Programming.Groups.Suggest;
|
||||||
using TeleWave.Application.Programming.Groups.UpdateGroup;
|
using TeleWave.Application.Programming.Groups.UpdateGroup;
|
||||||
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
@@ -34,6 +37,12 @@ public static class GroupEndpoints
|
|||||||
.MapPost("/{id:guid}/candidates", FindCandidates)
|
.MapPost("/{id:guid}/candidates", FindCandidates)
|
||||||
.Produces<IReadOnlyList<GroupCandidateDto>>();
|
.Produces<IReadOnlyList<GroupCandidateDto>>();
|
||||||
|
|
||||||
|
// Предложения: что имеет смысл завести группой при нынешней библиотеке.
|
||||||
|
admin.MapGet("/suggestions", Suggestions).Produces<IReadOnlyList<GroupSuggestionDto>>();
|
||||||
|
admin
|
||||||
|
.MapPost("/suggestions", CreateFromSuggestion)
|
||||||
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
|
|
||||||
admin.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
|
admin.MapPost("/{id:guid}/items", AddElements).Produces<AddedCountResponse>();
|
||||||
admin
|
admin
|
||||||
.MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem)
|
.MapDelete("/{id:guid}/items/{itemId:guid}", RemoveItem)
|
||||||
@@ -43,6 +52,9 @@ public static class GroupEndpoints
|
|||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/{id:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
|
// Поправки к вычисленному составу динамической группы.
|
||||||
|
admin.MapPost("/{id:guid}/exclusions", Exclude).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +92,33 @@ public static class GroupEndpoints
|
|||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Suggestions(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new SuggestGroupsQuery(), cancellationToken);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateFromSuggestion(
|
||||||
|
CreateGroupFromSuggestionBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new CreateGroupFromSuggestionCommand(body.Key),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Created(
|
||||||
|
$"/api/admin/groups/{result.Value}",
|
||||||
|
new CreatedIdResponse(result.Value)
|
||||||
|
)
|
||||||
|
: result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateGroup(
|
private static async Task<IResult> UpdateGroup(
|
||||||
Guid id,
|
Guid id,
|
||||||
UpdateGroupBody body,
|
UpdateGroupBody body,
|
||||||
@@ -88,7 +127,7 @@ public static class GroupEndpoints
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter),
|
new UpdateGroupCommand(id, body.Name, body.Description, body.Filter, body.Mode),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -160,6 +199,20 @@ public static class GroupEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Exclude(
|
||||||
|
Guid id,
|
||||||
|
ExcludeElementBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new ExcludeGroupElementCommand(id, body.ElementKind, body.ElementId, body.Excluded),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Reorder(
|
private static async Task<IResult> Reorder(
|
||||||
Guid id,
|
Guid id,
|
||||||
ReorderGroupBody body,
|
ReorderGroupBody body,
|
||||||
@@ -175,10 +228,24 @@ public static class GroupEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UpdateGroupBody(string Name, string? Description, GroupFilter? Filter);
|
public sealed record UpdateGroupBody(
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
GroupFilter? Filter,
|
||||||
|
GroupMode? Mode = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Исключить элемент из динамической группы либо вернуть его правилу.</summary>
|
||||||
|
public sealed record ExcludeElementBody(
|
||||||
|
GroupElementKind ElementKind,
|
||||||
|
Guid ElementId,
|
||||||
|
bool Excluded
|
||||||
|
);
|
||||||
|
|
||||||
public sealed record FindCandidatesBody(GroupFilter? Filter);
|
public sealed record FindCandidatesBody(GroupFilter? Filter);
|
||||||
|
|
||||||
|
public sealed record CreateGroupFromSuggestionBody(string Key);
|
||||||
|
|
||||||
public sealed record AddGroupElementsBody(IReadOnlyList<GroupElementRef> Elements);
|
public sealed record AddGroupElementsBody(IReadOnlyList<GroupElementRef> Elements);
|
||||||
|
|
||||||
public sealed record GroupItemWeightBody(int Weight);
|
public sealed record GroupItemWeightBody(int Weight);
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Xml;
|
||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Streaming.GetIptvGuide;
|
||||||
|
using TeleWave.Infrastructure.Streaming;
|
||||||
|
|
||||||
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выгрузка для внешних IPTV-плееров: M3U со списком каналов и XMLTV с программой передач.
|
||||||
|
///
|
||||||
|
/// Плеер не умеет ни Bearer, ни httpOnly-cookie — он умеет только открыть URL. Поэтому пропуск
|
||||||
|
/// зашит в сами ссылки файла долгим stream-токеном: **файл и есть доступ к эфиру**, и делиться им
|
||||||
|
/// нельзя. Срок — <c>Storage:IptvTokenDays</c>; блокировка зрителя обрывает доступ на ближайшей
|
||||||
|
/// перезагрузке плейлиста канала, а смена <c>Jwt:SigningKey</c> обнуляет все выданные разом.
|
||||||
|
/// </summary>
|
||||||
|
public static class IptvEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Сколько суток программы класть в XMLTV: горизонт ленты и так неделя.</summary>
|
||||||
|
private static readonly TimeSpan GuideWindow = TimeSpan.FromDays(7);
|
||||||
|
|
||||||
|
/// <summary>Заглушка иконки канала, когда своя не задана: фавикон сайта из wwwroot.</summary>
|
||||||
|
private const string FaviconPath = "/favicon.svg";
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapIptvEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var iptv = app.MapGroup("/api/iptv").WithTags("IPTV");
|
||||||
|
|
||||||
|
// Файл забирает браузер под обычной авторизацией — токен внутрь кладёт уже сервер.
|
||||||
|
iptv.MapGet("/playlist.m3u", Playlist).RequireAuthorization();
|
||||||
|
// XMLTV тянет сам плеер, у него из пропусков только токен из ссылки.
|
||||||
|
iptv.MapGet("/epg.xml", Guide);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Playlist(
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
StreamTokenService tokens,
|
||||||
|
HttpRequest request,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (currentUser.UserId is not { } userId)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
// Окно нулевое: плейлисту нужен только список каналов, программу плеер возьмёт из XMLTV.
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var channels = await sender.Send(new GetIptvGuideQuery(now, now), cancellationToken);
|
||||||
|
|
||||||
|
var (token, _) = tokens.IssueForIptv(userId);
|
||||||
|
var origin = Origin(request);
|
||||||
|
var body = RenderPlaylist(channels, origin, token);
|
||||||
|
|
||||||
|
return Results.File(
|
||||||
|
Encoding.UTF8.GetBytes(body),
|
||||||
|
"audio/x-mpegurl",
|
||||||
|
fileDownloadName: "telewave.m3u"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Guide(
|
||||||
|
HttpRequest request,
|
||||||
|
StreamTokenService tokens,
|
||||||
|
IIdentityService identity,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (tokens.Validate(request.Query["token"]) is not { } userId)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
// Та же сверка, что и у плейлиста канала: заблокированный зритель не должен продолжать
|
||||||
|
// тянуть гид по выданному раньше файлу.
|
||||||
|
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
||||||
|
if (profile is null || profile.IsBlocked)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var channels = await sender.Send(
|
||||||
|
new GetIptvGuideQuery(now, now.Add(GuideWindow)),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return Results.File(
|
||||||
|
RenderGuide(channels, Origin(request)),
|
||||||
|
"application/xml; charset=utf-8",
|
||||||
|
fileDownloadName: "telewave-epg.xml"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Абсолютный адрес сервиса. Плееру нужны именно абсолютные ссылки — относительные ему не от
|
||||||
|
/// чего считать. Схема и хост берутся из запроса: TLS внешний, и их подставляет
|
||||||
|
/// <c>ForwardedHeaders</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string Origin(HttpRequest request) => $"{request.Scheme}://{request.Host}";
|
||||||
|
|
||||||
|
private static string RenderPlaylist(
|
||||||
|
IReadOnlyList<IptvChannelDto> channels,
|
||||||
|
string origin,
|
||||||
|
string token
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"#EXTM3U x-tvg-url=\"{origin}/api/iptv/epg.xml?token={token}\"\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
// tvg-logo ставим всегда: своя иконка либо фавикон-заглушка — иначе в плеере пустой квадрат.
|
||||||
|
var logo = $" tvg-logo=\"{ChannelIcon(origin, channel.IconImageId)}\"";
|
||||||
|
var number = channel.Number is { } value
|
||||||
|
? $" tvg-chno=\"{value.ToString(CultureInfo.InvariantCulture)}\""
|
||||||
|
: string.Empty;
|
||||||
|
var name = Attribute(channel.Name);
|
||||||
|
|
||||||
|
sb.Append(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"#EXTINF:-1 tvg-id=\"{Attribute(channel.Slug)}\" tvg-name=\"{name}\"{number}{logo} group-title=\"TeleWave\",{name}\n"
|
||||||
|
);
|
||||||
|
sb.Append(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{origin}/api/channels/{channel.Slug}/live.m3u8?token={token}\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Значение атрибута M3U. Экранирования в формате нет вовсе, поэтому кавычки и переводы строк
|
||||||
|
/// не заменяются, а вырезаются: иначе название канала рвало бы строку файла.
|
||||||
|
/// </summary>
|
||||||
|
private static string Attribute(string value) =>
|
||||||
|
value.Replace("\"", string.Empty).ReplaceLineEndings(" ").Trim();
|
||||||
|
|
||||||
|
private static byte[] RenderGuide(IReadOnlyList<IptvChannelDto> channels, string origin)
|
||||||
|
{
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
// Пишем сразу в поток в UTF-8, а не в StringBuilder: XmlWriter поверх строки объявил бы
|
||||||
|
// encoding="utf-16" (строки .NET — UTF-16), тогда как байты мы всё равно отдаём в UTF-8.
|
||||||
|
// Часть плееров (Go encoding/xml) отвергает такое расхождение декларации и реальных байт.
|
||||||
|
// BOM не пишем: XMLTV его не ждёт, и он мешает строгим парсерам.
|
||||||
|
// Через XmlWriter, а не склейкой строк: в названия и описания приходит текст из библиотеки,
|
||||||
|
// и экранировать его руками — верный способ однажды отдать битый XML.
|
||||||
|
using (
|
||||||
|
var writer = XmlWriter.Create(
|
||||||
|
output,
|
||||||
|
new XmlWriterSettings
|
||||||
|
{
|
||||||
|
Indent = true,
|
||||||
|
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
writer.WriteStartDocument();
|
||||||
|
writer.WriteStartElement("tv");
|
||||||
|
writer.WriteAttributeString("generator-info-name", "TeleWave");
|
||||||
|
|
||||||
|
// Сначала все каналы, потом все передачи: XMLTV требует именно такого порядка секций.
|
||||||
|
foreach (var channel in channels)
|
||||||
|
WriteChannel(writer, channel, origin);
|
||||||
|
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
foreach (var programme in channel.Programmes)
|
||||||
|
WriteProgramme(writer, channel.Slug, programme, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteEndElement();
|
||||||
|
writer.WriteEndDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteChannel(XmlWriter writer, IptvChannelDto channel, string origin)
|
||||||
|
{
|
||||||
|
writer.WriteStartElement("channel");
|
||||||
|
writer.WriteAttributeString("id", channel.Slug);
|
||||||
|
writer.WriteElementString("display-name", channel.Name);
|
||||||
|
// Номер вторым display-name: так плееры с нумерацией подхватывают его без своих настроек.
|
||||||
|
if (channel.Number is { } number)
|
||||||
|
writer.WriteElementString(
|
||||||
|
"display-name",
|
||||||
|
number.ToString(CultureInfo.InvariantCulture)
|
||||||
|
);
|
||||||
|
// Иконку канала пишем всегда: своя либо фавикон-заглушка (в отличие от иконок передач ниже).
|
||||||
|
writer.WriteStartElement("icon");
|
||||||
|
writer.WriteAttributeString("src", ChannelIcon(origin, channel.IconImageId));
|
||||||
|
writer.WriteEndElement();
|
||||||
|
writer.WriteEndElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteProgramme(
|
||||||
|
XmlWriter writer,
|
||||||
|
string slug,
|
||||||
|
IptvProgrammeDto programme,
|
||||||
|
string origin
|
||||||
|
)
|
||||||
|
{
|
||||||
|
writer.WriteStartElement("programme");
|
||||||
|
writer.WriteAttributeString("start", XmltvTime(programme.StartsAtUtc));
|
||||||
|
writer.WriteAttributeString("stop", XmltvTime(programme.EndsAtUtc));
|
||||||
|
writer.WriteAttributeString("channel", slug);
|
||||||
|
|
||||||
|
writer.WriteElementString("title", programme.Title);
|
||||||
|
if (!string.IsNullOrWhiteSpace(programme.SubTitle))
|
||||||
|
writer.WriteElementString("sub-title", programme.SubTitle);
|
||||||
|
if (!string.IsNullOrWhiteSpace(programme.Description))
|
||||||
|
writer.WriteElementString("desc", programme.Description);
|
||||||
|
WriteIcon(writer, programme.ImageId, origin);
|
||||||
|
|
||||||
|
writer.WriteEndElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIcon(XmlWriter writer, Guid? imageId, string origin)
|
||||||
|
{
|
||||||
|
if (imageId is not { } id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
writer.WriteStartElement("icon");
|
||||||
|
writer.WriteAttributeString("src", $"{origin}/api/images/{id}");
|
||||||
|
writer.WriteEndElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>URL иконки канала: своя из реестра либо фавикон-заглушка сайта (абсолютный адрес).</summary>
|
||||||
|
private static string ChannelIcon(string origin, Guid? iconImageId) =>
|
||||||
|
iconImageId is { } id ? $"{origin}/api/images/{id}" : $"{origin}{FaviconPath}";
|
||||||
|
|
||||||
|
/// <summary>Время XMLTV: <c>YYYYMMDDHHMMSS +0000</c>. Отдаём в UTC — плеер сдвинет сам.</summary>
|
||||||
|
private static string XmltvTime(DateTimeOffset moment) =>
|
||||||
|
moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)
|
||||||
|
+ " +0000";
|
||||||
|
}
|
||||||
@@ -7,67 +7,50 @@ using TeleWave.Infrastructure.Identity;
|
|||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Шаблоны стыков канала: что играет между программами. Как и правка сетки, эфира не двигают —
|
/// Шаблоны стыков: что играет между программами. Стыки общие для всех каналов, канал только
|
||||||
/// помечают шаблон канала изменённым, а хвост пересобирается применением.
|
/// ссылается на них слотами. Как и правка сетки, эфира не двигают — помечают шаблоны каналов,
|
||||||
|
/// которые их используют, изменёнными, а хвост пересобирается применением.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class JunctionEndpoints
|
public static class JunctionEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapJunctionEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin")
|
var admin = app.MapGroup("/api/admin/junctions")
|
||||||
.WithTags("Admin.Junctions")
|
.WithTags("Admin.Junctions")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin
|
admin.MapGet("", List).Produces<IReadOnlyList<JunctionTemplateDto>>();
|
||||||
.MapGet("/channels/{channelId:guid}/junctions", List)
|
admin.MapPost("", Create).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
.Produces<IReadOnlyList<JunctionTemplateDto>>();
|
admin.MapPut("/{junctionId:guid}", Update).Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin.MapDelete("/{junctionId:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||||
.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
|
admin
|
||||||
.MapPost("/junctions/{junctionId:guid}/elements", AddElement)
|
.MapPost("/{junctionId:guid}/elements", AddElement)
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin
|
admin
|
||||||
.MapPut("/junctions/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
|
.MapPut("/{junctionId:guid}/elements/{elementId:guid}", UpdateElement)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapDelete("/junctions/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
|
.MapDelete("/{junctionId:guid}/elements/{elementId:guid}", RemoveElement)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
|
||||||
admin
|
|
||||||
.MapPut("/junctions/{junctionId:guid}/order", Reorder)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapPut("/{junctionId:guid}/order", Reorder).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> List(
|
private static async Task<IResult> List(ISender sender, CancellationToken cancellationToken)
|
||||||
Guid channelId,
|
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListJunctionsQuery(channelId), cancellationToken);
|
var result = await sender.Send(new ListJunctionsQuery(), cancellationToken);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Create(
|
private static async Task<IResult> Create(
|
||||||
Guid channelId,
|
|
||||||
JunctionNameBody body,
|
JunctionNameBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(new CreateJunctionCommand(body.Name), cancellationToken);
|
||||||
new CreateJunctionCommand(channelId, body.Name),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created(
|
? Results.Created(
|
||||||
$"/api/admin/junctions/{result.Value}",
|
$"/api/admin/junctions/{result.Value}",
|
||||||
@@ -76,15 +59,15 @@ public static class JunctionEndpoints
|
|||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Rename(
|
private static async Task<IResult> Update(
|
||||||
Guid junctionId,
|
Guid junctionId,
|
||||||
JunctionNameBody body,
|
UpdateJunctionBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new RenameJunctionCommand(junctionId, body.Name),
|
new UpdateJunctionCommand(junctionId, body.Name, body.MaxTotalSeconds),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -156,7 +139,7 @@ public static class JunctionEndpoints
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ReorderJunctionCommand(junctionId, body.ElementIdsInOrder),
|
new ReorderJunctionCommand(junctionId, body.Order),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -165,6 +148,9 @@ public static class JunctionEndpoints
|
|||||||
|
|
||||||
public sealed record JunctionNameBody(string Name);
|
public sealed record JunctionNameBody(string Name);
|
||||||
|
|
||||||
|
public sealed record UpdateJunctionBody(string Name, int? MaxTotalSeconds);
|
||||||
|
|
||||||
public sealed record JunctionElementKindBody(JunctionElementKind Kind);
|
public sealed record JunctionElementKindBody(JunctionElementKind Kind);
|
||||||
|
|
||||||
public sealed record ReorderJunctionBody(IReadOnlyList<Guid> ElementIdsInOrder);
|
/// <summary>Порядок врезок вместе с их развилками — перетаскивание меняет и то, и другое разом.</summary>
|
||||||
|
public sealed record ReorderJunctionBody(IReadOnlyList<JunctionElementOrder> Order);
|
||||||
|
|||||||
@@ -1,216 +1,306 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Media;
|
using TeleWave.Application.Media;
|
||||||
using TeleWave.Application.Media.Delete;
|
using TeleWave.Application.Media.Delete;
|
||||||
using TeleWave.Application.Media.ListMedia;
|
using TeleWave.Application.Media.ListMedia;
|
||||||
using TeleWave.Application.Media.ManualInbox;
|
using TeleWave.Application.Media.ManualInbox;
|
||||||
using TeleWave.Application.Media.Register;
|
using TeleWave.Application.Media.MovieImport;
|
||||||
using TeleWave.Application.Media.Stats;
|
using TeleWave.Application.Media.Register;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Application.Media.Retry;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Application.Media.Stats;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Domain.Media;
|
||||||
|
using TeleWave.Infrastructure.Identity;
|
||||||
namespace TeleWave.Api.Endpoints;
|
using TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
public static class MediaEndpoints
|
namespace TeleWave.Api.Endpoints;
|
||||||
{
|
|
||||||
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
public static class MediaEndpoints
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin/media")
|
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
||||||
.WithTags("Admin.Media")
|
{
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
var admin = app.MapGroup("/api/admin/media")
|
||||||
|
.WithTags("Admin.Media")
|
||||||
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
|
||||||
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
||||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
||||||
|
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
||||||
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
|
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
|
|
||||||
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
|
// Перезапуск упавшей обработки: файл на месте, повторить нарезку — обычное дело.
|
||||||
|
admin.MapPost("/{id:guid}/retry", Retry).Produces<RetryMediaResultDto>();
|
||||||
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
admin.MapPost("/retry-failed", RetryFailed).Produces<RetryMediaResultDto>();
|
||||||
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
|
|
||||||
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
|
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
|
||||||
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
|
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
|
||||||
|
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
|
||||||
return app;
|
|
||||||
}
|
// Разбор фильмов: имена приходят с клиента (каталог manual/ либо выбранные в браузере
|
||||||
|
// файлы), сервер разбирает их и ищет в источнике. Импорт заводит шоу на файл.
|
||||||
/// <summary>
|
admin.MapPost("/movies/match", MatchMovies).Produces<IReadOnlyList<MovieMatchDto>>();
|
||||||
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
|
admin.MapPost("/movies/match-one", MatchMovie).Produces<MovieMatchDto>();
|
||||||
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
|
admin.MapPost("/movies/import", ImportMovies).Produces<ImportMoviesResultDto>();
|
||||||
/// </summary>
|
|
||||||
private static async Task<IResult> Upload(
|
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
||||||
string fileName,
|
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
|
||||||
HttpRequest request,
|
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
|
||||||
IMediaStorage storage,
|
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
|
||||||
IMediaProcessingQueue queue,
|
|
||||||
ISender sender,
|
return app;
|
||||||
UploadLimits limits,
|
}
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
private static async Task<IResult> Retry(
|
||||||
{
|
Guid id,
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
ISender sender,
|
||||||
return Results.Problem(
|
CancellationToken cancellationToken
|
||||||
title: MediaErrors.EmptyFileName.Code,
|
)
|
||||||
detail: MediaErrors.EmptyFileName.Message,
|
{
|
||||||
statusCode: StatusCodes.Status400BadRequest
|
var result = await sender.Send(new RetryMediaCommand(id), cancellationToken);
|
||||||
);
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
if (!MediaFormats.IsAllowed(fileName))
|
|
||||||
return Results.Problem(
|
private static async Task<IResult> RetryFailed(
|
||||||
title: MediaErrors.UnsupportedFormat.Code,
|
ISender sender,
|
||||||
detail: MediaErrors.UnsupportedFormat.Message,
|
CancellationToken cancellationToken
|
||||||
statusCode: StatusCodes.Status400BadRequest
|
)
|
||||||
);
|
{
|
||||||
|
var result = await sender.Send(new RetryMediaCommand(), cancellationToken);
|
||||||
var contentLength = request.ContentLength ?? 0;
|
return result.ToHttpResult();
|
||||||
if (contentLength > limits.MaxUploadBytes)
|
}
|
||||||
return Results.Problem(
|
|
||||||
title: MediaErrors.FileTooLarge.Code,
|
/// <summary>
|
||||||
detail: MediaErrors.FileTooLarge.Message,
|
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
|
||||||
statusCode: StatusCodes.Status400BadRequest
|
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
|
||||||
);
|
/// </summary>
|
||||||
|
private static async Task<IResult> Upload(
|
||||||
var free = storage.GetAvailableFreeSpaceBytes();
|
string fileName,
|
||||||
if (free - contentLength < limits.MinFreeSpaceBytes)
|
HttpRequest request,
|
||||||
return Results.Problem(
|
IMediaStorage storage,
|
||||||
title: MediaErrors.InsufficientStorage.Code,
|
IMediaProcessingQueue queue,
|
||||||
detail: MediaErrors.InsufficientStorage.Message,
|
ISender sender,
|
||||||
statusCode: StatusCodes.Status409Conflict
|
UploadLimits limits,
|
||||||
);
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
var extension = Path.GetExtension(fileName);
|
{
|
||||||
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
return Results.Problem(
|
||||||
var result = await sender.Send(
|
title: MediaErrors.EmptyFileName.Code,
|
||||||
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
|
detail: MediaErrors.EmptyFileName.Message,
|
||||||
cancellationToken
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
);
|
);
|
||||||
if (!result.IsSuccess)
|
|
||||||
{
|
if (!MediaFormats.IsAllowed(fileName))
|
||||||
await storage.DeleteUploadAsync(token, cancellationToken);
|
return Results.Problem(
|
||||||
return result.ToHttpResult();
|
title: MediaErrors.UnsupportedFormat.Code,
|
||||||
}
|
detail: MediaErrors.UnsupportedFormat.Message,
|
||||||
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
queue.Enqueue(result.Value);
|
);
|
||||||
return Results.Created(
|
|
||||||
$"/api/admin/media/{result.Value}",
|
var contentLength = request.ContentLength ?? 0;
|
||||||
new UploadMediaResponse(result.Value)
|
if (contentLength > limits.MaxUploadBytes)
|
||||||
);
|
return Results.Problem(
|
||||||
}
|
title: MediaErrors.FileTooLarge.Code,
|
||||||
|
detail: MediaErrors.FileTooLarge.Message,
|
||||||
private static async Task<IResult> List(
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
[AsParameters] ListMediaFilter filter,
|
);
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
var free = storage.GetAvailableFreeSpaceBytes();
|
||||||
)
|
if (free - contentLength < limits.MinFreeSpaceBytes)
|
||||||
{
|
return Results.Problem(
|
||||||
var result = await sender.Send(
|
title: MediaErrors.InsufficientStorage.Code,
|
||||||
new ListMediaAssetsQuery(
|
detail: MediaErrors.InsufficientStorage.Message,
|
||||||
filter.Page is > 0 ? filter.Page.Value : 1,
|
statusCode: StatusCodes.Status409Conflict
|
||||||
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
|
);
|
||||||
filter.Status ?? [],
|
|
||||||
filter.Search,
|
var extension = Path.GetExtension(fileName);
|
||||||
filter.Sort,
|
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
|
||||||
filter.Desc ?? false
|
|
||||||
),
|
// Файл уже на диске в uploads/, но в БД его ещё нет и ссылок на токен нигде не остаётся:
|
||||||
cancellationToken
|
// любой исход, кроме успешной регистрации, обязан за собой убрать. Удаляем и при исключении
|
||||||
);
|
// (оборванный запрос, сбой БД) — иначе гигабайты остаются в uploads/ до уборщика.
|
||||||
return Results.Ok(result);
|
// Токен отмены для уборки не пробрасываем: при обрыве запроса он уже сработал.
|
||||||
}
|
Result<Guid> result;
|
||||||
|
try
|
||||||
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
|
{
|
||||||
{
|
result = await sender.Send(
|
||||||
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
|
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
|
||||||
return Results.Ok(result);
|
cancellationToken
|
||||||
}
|
);
|
||||||
|
}
|
||||||
private static async Task<IResult> Delete(
|
catch
|
||||||
Guid id,
|
{
|
||||||
ISender sender,
|
await storage.DeleteUploadAsync(token, CancellationToken.None);
|
||||||
CancellationToken cancellationToken
|
throw;
|
||||||
)
|
}
|
||||||
{
|
|
||||||
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
|
if (!result.IsSuccess)
|
||||||
return result.ToHttpResult();
|
{
|
||||||
}
|
await storage.DeleteUploadAsync(token, CancellationToken.None);
|
||||||
|
return result.ToHttpResult();
|
||||||
private static async Task<IResult> ListManual(
|
}
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
queue.Enqueue(result.Value);
|
||||||
)
|
return Results.Created(
|
||||||
{
|
$"/api/admin/media/{result.Value}",
|
||||||
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
|
new UploadMediaResponse(result.Value)
|
||||||
return Results.Ok(result);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ImportManual(
|
private static async Task<IResult> List(
|
||||||
ImportManualInboxBody body,
|
[AsParameters] ListMediaFilter filter,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ImportManualInboxCommand(body.Items, body.ShowId),
|
new ListMediaAssetsQuery(
|
||||||
cancellationToken
|
filter.Page is > 0 ? filter.Page.Value : 1,
|
||||||
);
|
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
|
||||||
return result.ToHttpResult();
|
filter.Status ?? [],
|
||||||
}
|
filter.Search,
|
||||||
|
filter.Sort,
|
||||||
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
filter.Desc ?? false
|
||||||
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
),
|
||||||
{
|
cancellationToken
|
||||||
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
|
);
|
||||||
return Results.NotFound();
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
var baseUrl = $"/api/admin/media/{id}/preview/";
|
|
||||||
var sb = new StringBuilder();
|
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
|
||||||
foreach (var line in File.ReadLines(indexPath))
|
{
|
||||||
{
|
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
|
||||||
var trimmed = line.Trim();
|
return Results.Ok(result);
|
||||||
if (trimmed.Length == 0)
|
}
|
||||||
continue;
|
|
||||||
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
|
private static async Task<IResult> Delete(
|
||||||
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
|
Guid id,
|
||||||
.Append('\n');
|
ISender sender,
|
||||||
}
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
|
{
|
||||||
}
|
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
|
}
|
||||||
{
|
|
||||||
if (!SegmentFiles.IsSegmentName(file))
|
private static async Task<IResult> ListManual(
|
||||||
return Results.NotFound();
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
|
)
|
||||||
return Results.NotFound();
|
{
|
||||||
|
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
|
||||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private static async Task<IResult> MatchMovies(
|
||||||
/// <summary>
|
MatchMoviesBody body,
|
||||||
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
|
ISender sender,
|
||||||
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
|
CancellationToken cancellationToken
|
||||||
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
|
)
|
||||||
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
|
{
|
||||||
/// </summary>
|
var result = await sender.Send(
|
||||||
public sealed record ListMediaFilter(
|
new MatchMoviesQuery(body.Names, body.Provider),
|
||||||
int? Page,
|
cancellationToken
|
||||||
int? PageSize,
|
);
|
||||||
MediaAssetStatus[]? Status,
|
return Results.Ok(result);
|
||||||
string? Search,
|
}
|
||||||
string? Sort,
|
|
||||||
bool? Desc
|
private static async Task<IResult> MatchMovie(
|
||||||
);
|
MatchMovieBody body,
|
||||||
|
ISender sender,
|
||||||
public sealed record UploadMediaResponse(Guid Id);
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new MatchMovieQuery(body.Name, body.Title, body.Year, body.Provider),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ImportMovies(
|
||||||
|
ImportMoviesBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new ImportMoviesCommand(body.Items, body.Provider),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ImportManual(
|
||||||
|
ImportManualInboxBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new ImportManualInboxCommand(body.Items, body.ShowId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||||
|
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
||||||
|
{
|
||||||
|
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var baseUrl = $"/api/admin/media/{id}/preview/";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var line in File.ReadLines(indexPath))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.Length == 0)
|
||||||
|
continue;
|
||||||
|
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
|
||||||
|
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
|
||||||
|
.Append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
|
||||||
|
{
|
||||||
|
if (!SegmentFiles.IsSegmentName(file))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
|
||||||
|
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
|
||||||
|
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
|
||||||
|
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ListMediaFilter(
|
||||||
|
int? Page,
|
||||||
|
int? PageSize,
|
||||||
|
MediaAssetStatus[]? Status,
|
||||||
|
string? Search,
|
||||||
|
string? Sort,
|
||||||
|
bool? Desc
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record UploadMediaResponse(Guid Id);
|
||||||
|
|
||||||
|
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
|
||||||
|
|
||||||
|
public sealed record MatchMoviesBody(IReadOnlyList<string> Names, string Provider);
|
||||||
|
|
||||||
|
public sealed record MatchMovieBody(string Name, string Title, int? Year, string Provider);
|
||||||
|
|
||||||
|
public sealed record ImportMoviesBody(IReadOnlyList<MovieImportItem> Items, string Provider);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using TeleWave.Application.Metadata;
|
|||||||
using TeleWave.Application.Metadata.ApplyShowMetadata;
|
using TeleWave.Application.Metadata.ApplyShowMetadata;
|
||||||
using TeleWave.Application.Metadata.ClearShowMetadata;
|
using TeleWave.Application.Metadata.ClearShowMetadata;
|
||||||
using TeleWave.Application.Metadata.FindMissingEpisodes;
|
using TeleWave.Application.Metadata.FindMissingEpisodes;
|
||||||
|
using TeleWave.Application.Metadata.FindMissingSeasons;
|
||||||
using TeleWave.Application.Metadata.GetProviders;
|
using TeleWave.Application.Metadata.GetProviders;
|
||||||
using TeleWave.Application.Metadata.RefreshEpisodes;
|
using TeleWave.Application.Metadata.RefreshEpisodes;
|
||||||
using TeleWave.Application.Metadata.SearchShows;
|
using TeleWave.Application.Metadata.SearchShows;
|
||||||
@@ -34,6 +35,9 @@ public static class MetadataEndpoints
|
|||||||
admin
|
admin
|
||||||
.MapGet("/shows/{showId:guid}/missing-episodes", FindMissing)
|
.MapGet("/shows/{showId:guid}/missing-episodes", FindMissing)
|
||||||
.Produces<MissingEpisodesReport>();
|
.Produces<MissingEpisodesReport>();
|
||||||
|
admin
|
||||||
|
.MapGet("/shows/{showId:guid}/missing-seasons", FindMissingSeasons)
|
||||||
|
.Produces<MissingSeasonsReport>();
|
||||||
|
|
||||||
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}.
|
// Постеры шоу и кадры серий теперь в общем реестре и отдаются по /api/images/{id}.
|
||||||
return app;
|
return app;
|
||||||
@@ -137,6 +141,16 @@ public static class MetadataEndpoints
|
|||||||
var result = await sender.Send(new FindMissingEpisodesQuery(showId), cancellationToken);
|
var result = await sender.Send(new FindMissingEpisodesQuery(showId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> FindMissingSeasons(
|
||||||
|
Guid showId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new FindMissingSeasonsQuery(showId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
|
public sealed record ApplyMetadataBody(string Provider, string ExternalId);
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Settings;
|
using TeleWave.Application.Settings;
|
||||||
using TeleWave.Application.Settings.GetSiteSettings;
|
using TeleWave.Application.Settings.GetSiteSettings;
|
||||||
using TeleWave.Application.Settings.UpdateSiteSettings;
|
using TeleWave.Application.Settings.UpdateSiteSettings;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
public static class SettingsEndpoints
|
public static class SettingsEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin/settings")
|
var admin = app.MapGroup("/api/admin/settings")
|
||||||
.WithTags("Admin.Settings")
|
.WithTags("Admin.Settings")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
|
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
|
||||||
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetSettings(
|
private static async Task<IResult> GetSettings(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
|
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
|
||||||
return Results.Ok(settings);
|
return Results.Ok(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateSettings(
|
private static async Task<IResult> UpdateSettings(
|
||||||
UpdateSiteSettingsBody body,
|
UpdateSiteSettingsBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateSiteSettingsCommand(
|
new UpdateSiteSettingsCommand(
|
||||||
body.RegistrationEnabled,
|
body.RegistrationEnabled,
|
||||||
body.PreferredAudioLanguages ?? "",
|
body.PreferredAudioLanguages ?? "",
|
||||||
body.ChannelNumbersEnabled
|
body.ChannelNumbersEnabled
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UpdateSiteSettingsBody(
|
public sealed record UpdateSiteSettingsBody(
|
||||||
bool RegistrationEnabled,
|
bool RegistrationEnabled,
|
||||||
string? PreferredAudioLanguages,
|
string? PreferredAudioLanguages,
|
||||||
bool ChannelNumbersEnabled
|
bool ChannelNumbersEnabled
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,186 +1,259 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
using TeleWave.Application.Library.AddEpisode;
|
using TeleWave.Application.Library.AddEpisode;
|
||||||
using TeleWave.Application.Library.CreateShow;
|
using TeleWave.Application.Library.BulkTagShows;
|
||||||
using TeleWave.Application.Library.DeleteShow;
|
using TeleWave.Application.Library.CreateShow;
|
||||||
using TeleWave.Application.Library.GetShow;
|
using TeleWave.Application.Library.DeleteShow;
|
||||||
using TeleWave.Application.Library.ListShows;
|
using TeleWave.Application.Library.GetShow;
|
||||||
using TeleWave.Application.Library.RemoveEpisode;
|
using TeleWave.Application.Library.GetShowUsage;
|
||||||
using TeleWave.Application.Library.RenameShow;
|
using TeleWave.Application.Library.ListShows;
|
||||||
using TeleWave.Application.Library.SetShowAudience;
|
using TeleWave.Application.Library.RemoveEpisode;
|
||||||
using TeleWave.Application.Library.SetShowGenres;
|
using TeleWave.Application.Library.RenameShow;
|
||||||
using TeleWave.Application.Library.SetShowOriginalName;
|
using TeleWave.Application.Library.SetShowAudience;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Application.Library.SetShowGenres;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Application.Library.SetShowOriginalName;
|
||||||
|
using TeleWave.Application.Metadata.EnrichShows;
|
||||||
namespace TeleWave.Api.Endpoints;
|
using TeleWave.Domain.Library;
|
||||||
|
using TeleWave.Infrastructure.Identity;
|
||||||
public static class ShowEndpoints
|
|
||||||
{
|
namespace TeleWave.Api.Endpoints;
|
||||||
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
|
|
||||||
{
|
public static class ShowEndpoints
|
||||||
var admin = app.MapGroup("/api/admin/shows")
|
{
|
||||||
.WithTags("Admin.Shows")
|
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/shows")
|
||||||
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.WithTags("Admin.Shows")
|
||||||
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
|
|
||||||
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
|
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin
|
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
|
||||||
.MapPut("/{id:guid}/original-name", SetOriginalName)
|
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
|
admin
|
||||||
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
|
.MapPut("/{id:guid}/original-name", SetOriginalName)
|
||||||
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
|
||||||
.MapPost("/{id:guid}/episodes", AddEpisode)
|
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
admin.MapGet("/{id:guid}/usage", GetUsage).Produces<ShowUsageDto>();
|
||||||
admin
|
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
|
||||||
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
|
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
// Массовые операции над отмеченными шоу: разметка руками и обогащение из источника.
|
||||||
|
admin.MapPost("/bulk/tag", BulkTag).Produces<BulkTaggedResponse>();
|
||||||
return app;
|
admin.MapPost("/bulk/enrich", BulkEnrich).Produces<EnrichShowsResultDto>();
|
||||||
}
|
admin
|
||||||
|
.MapPost("/{id:guid}/episodes", AddEpisode)
|
||||||
private static async Task<IResult> CreateShow(
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
CreateShowCommand command,
|
admin
|
||||||
ISender sender,
|
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
|
||||||
CancellationToken cancellationToken
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
)
|
|
||||||
{
|
return app;
|
||||||
var result = await sender.Send(command, cancellationToken);
|
}
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created(
|
private static async Task<IResult> CreateShow(
|
||||||
$"/api/admin/shows/{result.Value}",
|
CreateShowCommand command,
|
||||||
new CreatedIdResponse(result.Value)
|
ISender sender,
|
||||||
)
|
CancellationToken cancellationToken
|
||||||
: result.ToHttpResult();
|
)
|
||||||
}
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
private static async Task<IResult> ListShows(
|
return result.IsSuccess
|
||||||
ISender sender,
|
? Results.Created(
|
||||||
CancellationToken cancellationToken,
|
$"/api/admin/shows/{result.Value}",
|
||||||
Guid? genreId = null,
|
new CreatedIdResponse(result.Value)
|
||||||
bool interstitials = false
|
)
|
||||||
)
|
: result.ToHttpResult();
|
||||||
{
|
}
|
||||||
var result = await sender.Send(
|
|
||||||
new ListShowsQuery(genreId, interstitials),
|
private static async Task<IResult> ListShows(
|
||||||
cancellationToken
|
ISender sender,
|
||||||
);
|
CancellationToken cancellationToken,
|
||||||
return Results.Ok(result);
|
Guid? genreId = null,
|
||||||
}
|
bool interstitials = false
|
||||||
|
)
|
||||||
private static async Task<IResult> GetShow(
|
{
|
||||||
Guid id,
|
var result = await sender.Send(
|
||||||
ISender sender,
|
new ListShowsQuery(genreId, interstitials),
|
||||||
CancellationToken cancellationToken
|
cancellationToken
|
||||||
)
|
);
|
||||||
{
|
return Results.Ok(result);
|
||||||
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
|
}
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
private static async Task<IResult> GetShow(
|
||||||
|
Guid id,
|
||||||
private static async Task<IResult> Rename(
|
ISender sender,
|
||||||
Guid id,
|
CancellationToken cancellationToken
|
||||||
RenameShowBody body,
|
)
|
||||||
ISender sender,
|
{
|
||||||
CancellationToken cancellationToken
|
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
|
||||||
)
|
return result.ToHttpResult();
|
||||||
{
|
}
|
||||||
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
private static async Task<IResult> Rename(
|
||||||
}
|
Guid id,
|
||||||
|
RenameShowBody body,
|
||||||
private static async Task<IResult> SetOriginalName(
|
ISender sender,
|
||||||
Guid id,
|
CancellationToken cancellationToken
|
||||||
SetShowOriginalNameBody body,
|
)
|
||||||
ISender sender,
|
{
|
||||||
CancellationToken cancellationToken
|
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
|
||||||
)
|
return result.ToHttpResult();
|
||||||
{
|
}
|
||||||
var result = await sender.Send(
|
|
||||||
new SetShowOriginalNameCommand(id, body.OriginalName),
|
private static async Task<IResult> SetOriginalName(
|
||||||
cancellationToken
|
Guid id,
|
||||||
);
|
SetShowOriginalNameBody body,
|
||||||
return result.ToHttpResult();
|
ISender sender,
|
||||||
}
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
private static async Task<IResult> SetAudience(
|
{
|
||||||
Guid id,
|
var result = await sender.Send(
|
||||||
SetShowAudienceBody body,
|
new SetShowOriginalNameCommand(id, body.OriginalName),
|
||||||
ISender sender,
|
cancellationToken
|
||||||
CancellationToken cancellationToken
|
);
|
||||||
)
|
return result.ToHttpResult();
|
||||||
{
|
}
|
||||||
var result = await sender.Send(
|
|
||||||
new SetShowAudienceCommand(id, body.Audience),
|
private static async Task<IResult> SetAudience(
|
||||||
cancellationToken
|
Guid id,
|
||||||
);
|
SetShowAudienceBody body,
|
||||||
return result.ToHttpResult();
|
ISender sender,
|
||||||
}
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
private static async Task<IResult> SetGenres(
|
{
|
||||||
Guid id,
|
var result = await sender.Send(
|
||||||
SetShowGenresBody body,
|
new SetShowAudienceCommand(id, body.Audience),
|
||||||
ISender sender,
|
cancellationToken
|
||||||
CancellationToken cancellationToken
|
);
|
||||||
)
|
return result.ToHttpResult();
|
||||||
{
|
}
|
||||||
var result = await sender.Send(
|
|
||||||
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
|
private static async Task<IResult> SetGenres(
|
||||||
cancellationToken
|
Guid id,
|
||||||
);
|
SetShowGenresBody body,
|
||||||
return result.ToHttpResult();
|
ISender sender,
|
||||||
}
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
private static async Task<IResult> DeleteShow(
|
{
|
||||||
Guid id,
|
var result = await sender.Send(
|
||||||
ISender sender,
|
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
|
||||||
CancellationToken cancellationToken
|
cancellationToken
|
||||||
)
|
);
|
||||||
{
|
return result.ToHttpResult();
|
||||||
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
|
}
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
/// <param name="withMedia">Снести заодно файлы шоу — иначе они останутся осиротевшими.</param>
|
||||||
|
/// <param name="withSchedule">Вырезать шоу из будущего эфира вместо отказа.</param>
|
||||||
private static async Task<IResult> AddEpisode(
|
private static async Task<IResult> DeleteShow(
|
||||||
Guid id,
|
Guid id,
|
||||||
AddEpisodeBody body,
|
ISender sender,
|
||||||
ISender sender,
|
CancellationToken cancellationToken,
|
||||||
CancellationToken cancellationToken
|
bool withMedia = false,
|
||||||
)
|
bool withSchedule = false
|
||||||
{
|
)
|
||||||
var result = await sender.Send(
|
{
|
||||||
new AddEpisodeCommand(id, body.MediaAssetId),
|
var result = await sender.Send(
|
||||||
cancellationToken
|
new DeleteShowCommand(id, withMedia, withSchedule),
|
||||||
);
|
cancellationToken
|
||||||
return result.IsSuccess
|
);
|
||||||
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
|
return result.ToHttpResult();
|
||||||
: result.ToHttpResult();
|
}
|
||||||
}
|
|
||||||
|
private static async Task<IResult> AddEpisode(
|
||||||
private static async Task<IResult> RemoveEpisode(
|
Guid id,
|
||||||
Guid id,
|
AddEpisodeBody body,
|
||||||
Guid episodeId,
|
ISender sender,
|
||||||
ISender sender,
|
CancellationToken cancellationToken
|
||||||
CancellationToken cancellationToken
|
)
|
||||||
)
|
{
|
||||||
{
|
var result = await sender.Send(
|
||||||
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
|
new AddEpisodeCommand(id, body.MediaAssetId),
|
||||||
return result.ToHttpResult();
|
cancellationToken
|
||||||
}
|
);
|
||||||
}
|
return result.IsSuccess
|
||||||
|
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
|
||||||
public sealed record AddEpisodeBody(Guid MediaAssetId);
|
: result.ToHttpResult();
|
||||||
|
}
|
||||||
public sealed record RenameShowBody(string Name);
|
|
||||||
|
private static async Task<IResult> RemoveEpisode(
|
||||||
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
Guid id,
|
||||||
|
Guid episodeId,
|
||||||
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
|
ISender sender,
|
||||||
public sealed record SetShowAudienceBody(ShowAudience? Audience);
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
|
{
|
||||||
|
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetUsage(
|
||||||
|
Guid id,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetShowUsageQuery(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> BulkTag(
|
||||||
|
BulkTagShowsBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new BulkTagShowsCommand(
|
||||||
|
body.ShowIds,
|
||||||
|
body.GenreIds,
|
||||||
|
body.ReplaceGenres,
|
||||||
|
body.Audience,
|
||||||
|
body.SetAudience
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Ok(new BulkTaggedResponse(result.Value))
|
||||||
|
: result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> BulkEnrich(
|
||||||
|
BulkEnrichBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new EnrichShowsMetadataCommand(body.ShowIds, body.Provider),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AddEpisodeBody(Guid MediaAssetId);
|
||||||
|
|
||||||
|
/// <summary>Массовая разметка: жанры и рейтинг применяются независимо друг от друга.</summary>
|
||||||
|
public sealed record BulkTagShowsBody(
|
||||||
|
IReadOnlyList<Guid> ShowIds,
|
||||||
|
IReadOnlyList<Guid>? GenreIds = null,
|
||||||
|
bool ReplaceGenres = false,
|
||||||
|
ShowAudience? Audience = null,
|
||||||
|
bool SetAudience = false
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record BulkEnrichBody(IReadOnlyList<Guid> ShowIds, string Provider);
|
||||||
|
|
||||||
|
/// <summary>Скольких шоу коснулась массовая правка.</summary>
|
||||||
|
public sealed record BulkTaggedResponse(int Updated);
|
||||||
|
|
||||||
|
public sealed record RenameShowBody(string Name);
|
||||||
|
|
||||||
|
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
||||||
|
|
||||||
|
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
|
||||||
|
public sealed record SetShowAudienceBody(ShowAudience? Audience);
|
||||||
|
|
||||||
|
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Storage;
|
||||||
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>Чем занят диск: объём по областям хранилища и заполненность тома.</summary>
|
||||||
|
public static class StorageEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapStorageEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/admin/storage", Stats)
|
||||||
|
.WithTags("Admin.Storage")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))
|
||||||
|
.Produces<StorageStatsDto>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="refresh">Пересчитать, не дожидаясь истечения кэша: обход дерева долгий.</param>
|
||||||
|
private static async Task<IResult> Stats(
|
||||||
|
bool? refresh,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new GetStorageStatsQuery(refresh ?? false),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,191 +1,214 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
using TeleWave.Application.Streaming.GetLivePlaylist;
|
using TeleWave.Application.Streaming.GetLivePlaylist;
|
||||||
using TeleWave.Application.Streaming.GetPublicEpg;
|
using TeleWave.Application.Streaming.GetPublicEpg;
|
||||||
using TeleWave.Application.Streaming.ListPublicChannels;
|
using TeleWave.Application.Streaming.ListPublicChannels;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Infrastructure.Media;
|
||||||
using TeleWave.Infrastructure.Streaming;
|
using TeleWave.Infrastructure.Streaming;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
public static class StreamingEndpoints
|
public static class StreamingEndpoints
|
||||||
{
|
{
|
||||||
private const string StreamCookieName = "tw_stream";
|
private const string StreamCookieName = "tw_stream";
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
|
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
|
||||||
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
|
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
|
||||||
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
|
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
|
||||||
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
|
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
|
||||||
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
|
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
|
||||||
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
|
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
|
||||||
channels.MapGet("/{slug}/epg", Epg);
|
channels.MapGet("/{slug}/epg", Epg);
|
||||||
|
|
||||||
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
|
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
|
||||||
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
|
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
|
||||||
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
|
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListChannels(
|
private static async Task<IResult> ListChannels(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
|
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ViewerFeatures(
|
private static async Task<IResult> ViewerFeatures(
|
||||||
ISiteSettings siteSettings,
|
ISiteSettings siteSettings,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) =>
|
) =>
|
||||||
Results.Ok(
|
Results.Ok(
|
||||||
new ViewerFeaturesDto(
|
new ViewerFeaturesDto(
|
||||||
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
|
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
|
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
|
||||||
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
|
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
|
||||||
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
|
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IResult Watch(
|
private static IResult Watch(
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
StreamTokenService tokens,
|
StreamTokenService tokens,
|
||||||
HttpRequest request,
|
HttpRequest request,
|
||||||
HttpResponse response,
|
HttpResponse response,
|
||||||
IHostEnvironment env
|
IHostEnvironment env
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is not { } userId)
|
if (currentUser.UserId is not { } userId)
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
|
|
||||||
var (token, expiresAt) = tokens.Issue(userId);
|
var (token, expiresAt) = tokens.Issue(userId);
|
||||||
response.Cookies.Append(
|
response.Cookies.Append(
|
||||||
StreamCookieName,
|
StreamCookieName,
|
||||||
token,
|
token,
|
||||||
new CookieOptions
|
new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
|
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
|
||||||
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
|
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
|
||||||
Secure = !env.IsDevelopment() || request.IsHttps,
|
Secure = !env.IsDevelopment() || request.IsHttps,
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
Path = "/api",
|
Path = "/api",
|
||||||
Expires = expiresAt,
|
Expires = expiresAt,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Epg(
|
private static async Task<IResult> Epg(
|
||||||
string slug,
|
string slug,
|
||||||
DateTimeOffset? from,
|
DateTimeOffset? from,
|
||||||
DateTimeOffset? to,
|
DateTimeOffset? to,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
||||||
var toUtc = to ?? fromUtc.AddHours(12);
|
var toUtc = to ?? fromUtc.AddHours(12);
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new GetPublicEpgQuery(slug, fromUtc, toUtc),
|
new GetPublicEpgQuery(slug, fromUtc, toUtc),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> LivePlaylist(
|
private static async Task<IResult> LivePlaylist(
|
||||||
string slug,
|
string slug,
|
||||||
HttpRequest request,
|
HttpRequest request,
|
||||||
HttpResponse response,
|
HttpResponse response,
|
||||||
StreamTokenService tokens,
|
StreamTokenService tokens,
|
||||||
IIdentityService identity,
|
IIdentityService identity,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
|
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
|
||||||
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
|
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
|
||||||
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
|
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
|
||||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
|
// Для IPTV это единственная точка, где блокировка вообще может сработать: у внешнего плеера
|
||||||
return Results.Unauthorized();
|
// пропуск долгий и перевыпуску не подлежит.
|
||||||
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
if (Viewer(request, tokens) is not { } userId)
|
||||||
if (profile is null || profile.IsBlocked)
|
return Results.Unauthorized();
|
||||||
return Results.Unauthorized();
|
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
||||||
|
if (profile is null || profile.IsBlocked)
|
||||||
var result = await sender.Send(
|
return Results.Unauthorized();
|
||||||
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
|
|
||||||
cancellationToken
|
var result = await sender.Send(
|
||||||
);
|
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
|
||||||
if (!result.IsSuccess)
|
cancellationToken
|
||||||
return result.ToHttpResult();
|
);
|
||||||
if (result.Value.Segments.Count == 0)
|
if (!result.IsSuccess)
|
||||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
return result.ToHttpResult();
|
||||||
|
if (result.Value.Segments.Count == 0)
|
||||||
response.Headers.CacheControl = "no-cache";
|
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||||
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
|
|
||||||
}
|
// Токен из ссылки тянется дальше в адреса сегментов: плеер разрешает их относительно
|
||||||
|
// плейлиста и query при этом теряет, а cookie у него нет — без этого сегменты дадут 401.
|
||||||
private static IResult Segment(
|
var token = request.Query["token"].ToString();
|
||||||
Guid assetId,
|
var suffix = string.IsNullOrEmpty(token)
|
||||||
string file,
|
? string.Empty
|
||||||
HttpRequest request,
|
: $"?token={Uri.EscapeDataString(token)}";
|
||||||
HttpResponse response,
|
|
||||||
StreamTokenService tokens,
|
response.Headers.CacheControl = "no-cache";
|
||||||
MediaPathResolver paths
|
return Results.Text(Render(result.Value, suffix), "application/vnd.apple.mpegurl");
|
||||||
)
|
}
|
||||||
{
|
|
||||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
|
private static IResult Segment(
|
||||||
return Results.Unauthorized();
|
Guid assetId,
|
||||||
if (!SegmentFiles.IsSegmentName(file))
|
string file,
|
||||||
return Results.NotFound();
|
HttpRequest request,
|
||||||
|
HttpResponse response,
|
||||||
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
|
StreamTokenService tokens,
|
||||||
return Results.NotFound();
|
MediaPathResolver paths
|
||||||
|
)
|
||||||
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
{
|
||||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
if (Viewer(request, tokens) is null)
|
||||||
}
|
return Results.Unauthorized();
|
||||||
|
if (!SegmentFiles.IsSegmentName(file))
|
||||||
private static string Render(LivePlaylistDto playlist)
|
return Results.NotFound();
|
||||||
{
|
|
||||||
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
|
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
|
||||||
var sb = new StringBuilder();
|
return Results.NotFound();
|
||||||
sb.Append("#EXTM3U\n");
|
|
||||||
sb.Append("#EXT-X-VERSION:3\n");
|
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||||
sb.Append(
|
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||||
CultureInfo.InvariantCulture,
|
}
|
||||||
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
|
|
||||||
);
|
/// <summary>
|
||||||
sb.Append(
|
/// Кто смотрит: cookie — для браузера, токен в ссылке — для внешнего IPTV-плеера, который
|
||||||
CultureInfo.InvariantCulture,
|
/// cookie не носит. Токен один и тот же по формату, различается только сроком выдачи.
|
||||||
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
|
/// </summary>
|
||||||
);
|
private static Guid? Viewer(HttpRequest request, StreamTokenService tokens) =>
|
||||||
|
tokens.Validate(request.Cookies[StreamCookieName])
|
||||||
foreach (var segment in playlist.Segments)
|
?? tokens.Validate(request.Query["token"]);
|
||||||
{
|
|
||||||
if (segment.Discontinuity)
|
private static string Render(LivePlaylistDto playlist, string segmentSuffix)
|
||||||
sb.Append("#EXT-X-DISCONTINUITY\n");
|
{
|
||||||
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
|
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
|
||||||
sb.Append(
|
var sb = new StringBuilder();
|
||||||
CultureInfo.InvariantCulture,
|
sb.Append("#EXTM3U\n");
|
||||||
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
|
sb.Append("#EXT-X-VERSION:3\n");
|
||||||
);
|
sb.Append(
|
||||||
}
|
CultureInfo.InvariantCulture,
|
||||||
|
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
|
||||||
return sb.ToString();
|
);
|
||||||
}
|
sb.Append(
|
||||||
}
|
CultureInfo.InvariantCulture,
|
||||||
|
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
|
||||||
/// <summary>Опции зрительской части, включённые глобально.</summary>
|
);
|
||||||
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
|
// Без DISCONTINUITY-SEQUENCE ffmpeg-плееры (Jellyfin) теряют счёт склеек, когда тег
|
||||||
|
// EXT-X-DISCONTINUITY уезжает за верхний край окна, и встают на стыке серий.
|
||||||
|
sb.Append(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"#EXT-X-DISCONTINUITY-SEQUENCE:{playlist.DiscontinuitySequence}\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var segment in playlist.Segments)
|
||||||
|
{
|
||||||
|
if (segment.Discontinuity)
|
||||||
|
sb.Append("#EXT-X-DISCONTINUITY\n");
|
||||||
|
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
|
||||||
|
sb.Append(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts{segmentSuffix}\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Опции зрительской части, включённые глобально.</summary>
|
||||||
|
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Api.Common;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Notifications;
|
||||||
|
using TeleWave.Application.Notifications.Link;
|
||||||
|
using TeleWave.Application.Notifications.Settings;
|
||||||
|
using TeleWave.Application.Notifications.Subscribers;
|
||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Бот расписания: настройка в админке, привязка чата зрителем и приём обновлений вебхуком.
|
||||||
|
/// </summary>
|
||||||
|
public static class TelegramEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>Заголовок, которым Telegram подтверждает, что вызов вебхука — его.</summary>
|
||||||
|
private const string SecretHeader = "X-Telegram-Bot-Api-Secret-Token";
|
||||||
|
|
||||||
|
public static IEndpointRouteBuilder MapTelegramEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin/telegram")
|
||||||
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
|
admin.MapGet("/settings", GetSettings).Produces<TelegramSettingsDto>();
|
||||||
|
admin.MapPut("/settings", UpdateSettings).Produces<TelegramSettingsDto>();
|
||||||
|
admin
|
||||||
|
.MapGet("/subscribers", ListSubscribers)
|
||||||
|
.Produces<IReadOnlyList<TelegramSubscriberDto>>();
|
||||||
|
|
||||||
|
// Настроен ли бот — публично: по этому флагу зрительская страница решает, показывать ли
|
||||||
|
// кнопку. Ничего, кроме флага и имени бота, здесь не отдаётся.
|
||||||
|
app.MapGet("/api/telegram/status", Status).AllowAnonymous().Produces<TelegramStatusDto>();
|
||||||
|
|
||||||
|
// Ссылка на чат — зрителю, а не админу: код привязывает его собственную учётку.
|
||||||
|
app.MapPost("/api/telegram/link", IssueLink)
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<TelegramLinkDto>();
|
||||||
|
|
||||||
|
// Вебхук открыт наружу по построению: его вызывает Telegram, а не наш фронт. Пускаем
|
||||||
|
// только по секрету из настроек — адрес рано или поздно окажется в чужих логах.
|
||||||
|
app.MapPost("/api/telegram/webhook", Webhook).AllowAnonymous();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> Status(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext
|
||||||
|
.TelegramSettings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
var link = settings is null ? null : TelegramSettingsService.BotLink(settings);
|
||||||
|
return Results.Ok(new TelegramStatusDto(link is not null, settings?.BotUsername));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetSettings(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetTelegramSettingsQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateSettings(
|
||||||
|
TelegramSettingsBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new UpdateTelegramSettingsCommand(
|
||||||
|
body.IsEnabled,
|
||||||
|
body.Transport,
|
||||||
|
body.BotToken,
|
||||||
|
body.WebhookUrl,
|
||||||
|
body.ProxyKind,
|
||||||
|
body.ProxyHost,
|
||||||
|
body.ProxyPort,
|
||||||
|
body.ProxyUsername,
|
||||||
|
body.ProxyPassword
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ListSubscribers(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new ListTelegramSubscribersQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> IssueLink(
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new IssueTelegramLinkCommand(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Приём обновления от Telegram. Разбираем тем же сервисом, что и опрос: транспорт разный,
|
||||||
|
/// разговор один.
|
||||||
|
///
|
||||||
|
/// Отвечаем 200 всегда, когда секрет сошёлся: на ошибку Telegram повторяет доставку, и одно
|
||||||
|
/// кривое обновление зациклило бы вебхук.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<IResult> Webhook(
|
||||||
|
HttpRequest request,
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
TelegramBotService bot,
|
||||||
|
ITelegramApi api,
|
||||||
|
ILoggerFactory loggers,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext.TelegramSettings.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (
|
||||||
|
settings is null
|
||||||
|
|| !settings.IsOperational
|
||||||
|
|| settings.Transport != TelegramTransport.Webhook
|
||||||
|
)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var secret = request.Headers[SecretHeader].ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.WebhookSecret) || secret != settings.WebhookSecret)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
var update = await api.ReadUpdateAsync(request.Body, cancellationToken);
|
||||||
|
if (update is null)
|
||||||
|
return Results.Ok();
|
||||||
|
|
||||||
|
var logger = loggers.CreateLogger("Telegram.Webhook");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await bot.HandleAsync(settings, update, DateTimeOffset.UtcNow, cancellationToken);
|
||||||
|
settings.MarkContact(DateTimeOffset.UtcNow);
|
||||||
|
settings.AdvanceUpdateId(update.UpdateId);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Не разобрано обновление {UpdateId}", update.UpdateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Что зрительской части нужно знать о боте: настроен ли и как он называется.</summary>
|
||||||
|
public sealed record TelegramStatusDto(bool IsConfigured, string? BotUsername);
|
||||||
|
|
||||||
|
/// <summary>Тело правки настроек: секреты необязательны — пусто значит «не менять».</summary>
|
||||||
|
public sealed record TelegramSettingsBody(
|
||||||
|
bool IsEnabled,
|
||||||
|
TelegramTransport Transport,
|
||||||
|
string? BotToken,
|
||||||
|
string? WebhookUrl,
|
||||||
|
TelegramProxyKind ProxyKind,
|
||||||
|
string? ProxyHost,
|
||||||
|
int? ProxyPort,
|
||||||
|
string? ProxyUsername,
|
||||||
|
string? ProxyPassword
|
||||||
|
);
|
||||||
@@ -1,296 +1,434 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
||||||
using TeleWave.Application.Programming.Planning.Diff;
|
using TeleWave.Application.Programming.Planning.Diff;
|
||||||
using TeleWave.Application.Programming.Planning.Preview;
|
using TeleWave.Application.Programming.Planning.Preview;
|
||||||
using TeleWave.Application.Programming.Templates;
|
using TeleWave.Application.Programming.Templates;
|
||||||
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.CreateSlot;
|
using TeleWave.Application.Programming.Templates.CreateSlot;
|
||||||
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.DeleteSlot;
|
using TeleWave.Application.Programming.Templates.DeleteSlot;
|
||||||
using TeleWave.Application.Programming.Templates.GetTemplate;
|
using TeleWave.Application.Programming.Templates.Generate;
|
||||||
using TeleWave.Application.Programming.Templates.Layers;
|
using TeleWave.Application.Programming.Templates.GetTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.UpdateSlot;
|
using TeleWave.Application.Programming.Templates.Layers;
|
||||||
using TeleWave.Application.Programming.Templates.Validate;
|
using TeleWave.Application.Programming.Templates.Reset;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Application.Programming.Templates.Restore;
|
||||||
|
using TeleWave.Application.Programming.Templates.Transfer;
|
||||||
namespace TeleWave.Api.Endpoints;
|
using TeleWave.Application.Programming.Templates.UpdateSlot;
|
||||||
|
using TeleWave.Application.Programming.Templates.Validate;
|
||||||
/// <summary>
|
using TeleWave.Infrastructure.Identity;
|
||||||
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
|
|
||||||
/// изменённым, а хвост пересобирается отдельной командой применения.
|
namespace TeleWave.Api.Endpoints;
|
||||||
/// </summary>
|
|
||||||
public static class TemplateEndpoints
|
/// <summary>
|
||||||
{
|
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
|
||||||
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
|
/// изменённым, а хвост пересобирается отдельной командой применения.
|
||||||
{
|
/// </summary>
|
||||||
var admin = app.MapGroup("/api/admin")
|
public static class TemplateEndpoints
|
||||||
.WithTags("Admin.Templates")
|
{
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
admin
|
var admin = app.MapGroup("/api/admin")
|
||||||
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
|
.WithTags("Admin.Templates")
|
||||||
.Produces<ScheduleTemplateDto>();
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
|
|
||||||
admin
|
admin
|
||||||
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
|
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.Produces<ScheduleTemplateDto>();
|
||||||
admin
|
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
|
||||||
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
|
admin
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
|
||||||
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
|
admin
|
||||||
admin
|
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
|
||||||
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
.Produces<ApplyResultDto>();
|
|
||||||
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
|
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
|
||||||
admin
|
admin
|
||||||
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
|
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
|
||||||
.Produces<SchedulePreviewDto>();
|
.Produces<ApplyResultDto>();
|
||||||
// Проверки по правилам — только по шаблону, без прогона генератора.
|
// Откат правок к состоянию, по которому идёт эфир. Ленту не трогает — она и так по нему.
|
||||||
admin
|
admin
|
||||||
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
|
.MapPost("/channels/{channelId:guid}/template/restore", RestoreTemplate)
|
||||||
.Produces<IReadOnlyList<TemplateIssueDto>>();
|
.Produces<RestoreTemplateResultDto>();
|
||||||
// Что изменится в эфире, если применить прямо сейчас.
|
// Сброс сетки вместе с собранным по ней будущим эфиром. Прошлое и идущая запись остаются.
|
||||||
admin
|
admin
|
||||||
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
|
.MapPost("/channels/{channelId:guid}/template/reset", ResetTemplate)
|
||||||
.Produces<ScheduleDiffDto>();
|
.Produces<ResetTemplateResultDto>();
|
||||||
admin
|
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
|
||||||
.MapPost(
|
admin
|
||||||
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
|
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
|
||||||
CopyTemplate
|
.Produces<SchedulePreviewDto>();
|
||||||
)
|
// Проверки по правилам — только по шаблону, без прогона генератора.
|
||||||
.Produces<CopyTemplateResultDto>();
|
admin
|
||||||
|
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
|
||||||
admin
|
.Produces<IReadOnlyList<TemplateIssueDto>>();
|
||||||
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
|
// Что изменится в эфире, если применить прямо сейчас.
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
admin
|
||||||
admin
|
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
|
||||||
.MapPut("/layers/{layerId:guid}", UpdateLayer)
|
.Produces<ScheduleDiffDto>();
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
admin
|
||||||
admin
|
.MapPost(
|
||||||
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
|
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
CopyTemplate
|
||||||
|
)
|
||||||
admin
|
.Produces<CopyTemplateResultDto>();
|
||||||
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
|
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
// Автосборка сетки: каталог профилей, план по выбранному и его создание. План считается
|
||||||
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
|
// одним и тем же кодом, поэтому предпросмотр показывает ровно то, что создаст кнопка.
|
||||||
admin
|
admin
|
||||||
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
|
.MapGet("/grid-profiles", ListGridProfiles)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces<IReadOnlyList<GridProfileDto>>();
|
||||||
|
admin
|
||||||
return app;
|
.MapGet("/channels/{channelId:guid}/template/grid-plan", PreviewGrid)
|
||||||
}
|
.Produces<GridPlanDto>();
|
||||||
|
admin
|
||||||
private static async Task<IResult> GetTemplate(
|
.MapPost("/channels/{channelId:guid}/template/generate", GenerateGrid)
|
||||||
Guid channelId,
|
.Produces<GenerateGridResultDto>();
|
||||||
ISender sender,
|
|
||||||
CancellationToken cancellationToken
|
// Обмен конфигурацией: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ.
|
||||||
)
|
// Запрос никуда не отправляется — админ копирует его в свою модель и приносит ответ назад.
|
||||||
{
|
admin
|
||||||
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
|
.MapGet("/channels/{channelId:guid}/template/export", ExportGrid)
|
||||||
return result.ToHttpResult();
|
.Produces<GridConfig>();
|
||||||
}
|
admin
|
||||||
|
.MapPost("/channels/{channelId:guid}/template/import", ImportGrid)
|
||||||
private static async Task<IResult> CreateTemplate(
|
.Produces<GridImportResultDto>();
|
||||||
Guid channelId,
|
admin
|
||||||
ISender sender,
|
.MapPost("/channels/{channelId:guid}/template/ai-prompt", BuildPrompt)
|
||||||
CancellationToken cancellationToken
|
.Produces<GridPromptDto>();
|
||||||
)
|
|
||||||
{
|
admin
|
||||||
var result = await sender.Send(
|
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
|
||||||
new CreateChannelTemplateCommand(channelId),
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
cancellationToken
|
admin
|
||||||
);
|
.MapPut("/layers/{layerId:guid}", UpdateLayer)
|
||||||
return result.IsSuccess
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
? Results.Created(
|
admin
|
||||||
$"/api/admin/channels/{channelId}/template",
|
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
|
||||||
new CreatedIdResponse(result.Value)
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
)
|
|
||||||
: result.ToHttpResult();
|
admin
|
||||||
}
|
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
|
||||||
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
private static async Task<IResult> ApplyTemplate(
|
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
|
||||||
Guid channelId,
|
admin
|
||||||
ISender sender,
|
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
|
||||||
CancellationToken cancellationToken
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
)
|
|
||||||
{
|
return app;
|
||||||
var result = await sender.Send(
|
}
|
||||||
new ApplyChannelTemplateCommand(channelId),
|
|
||||||
cancellationToken
|
private static async Task<IResult> GetTemplate(
|
||||||
);
|
Guid channelId,
|
||||||
return result.ToHttpResult();
|
ISender sender,
|
||||||
}
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
private static async Task<IResult> PreviewTemplate(
|
{
|
||||||
Guid channelId,
|
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
|
||||||
ISender sender,
|
return result.ToHttpResult();
|
||||||
CancellationToken cancellationToken,
|
}
|
||||||
int days = 1
|
|
||||||
)
|
private static async Task<IResult> CreateTemplate(
|
||||||
{
|
Guid channelId,
|
||||||
var result = await sender.Send(
|
ISender sender,
|
||||||
new PreviewScheduleQuery(channelId, days),
|
CancellationToken cancellationToken
|
||||||
cancellationToken
|
)
|
||||||
);
|
{
|
||||||
return result.ToHttpResult();
|
var result = await sender.Send(
|
||||||
}
|
new CreateChannelTemplateCommand(channelId),
|
||||||
|
cancellationToken
|
||||||
private static async Task<IResult> ValidateTemplate(
|
);
|
||||||
Guid channelId,
|
return result.IsSuccess
|
||||||
ISender sender,
|
? Results.Created(
|
||||||
CancellationToken cancellationToken
|
$"/api/admin/channels/{channelId}/template",
|
||||||
)
|
new CreatedIdResponse(result.Value)
|
||||||
{
|
)
|
||||||
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
|
: result.ToHttpResult();
|
||||||
return result.ToHttpResult();
|
}
|
||||||
}
|
|
||||||
|
private static async Task<IResult> ApplyTemplate(
|
||||||
private static async Task<IResult> DiffTemplate(
|
Guid channelId,
|
||||||
Guid channelId,
|
ISender sender,
|
||||||
ISender sender,
|
CancellationToken cancellationToken
|
||||||
CancellationToken cancellationToken
|
)
|
||||||
)
|
{
|
||||||
{
|
var result = await sender.Send(
|
||||||
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
|
new ApplyChannelTemplateCommand(channelId),
|
||||||
return result.ToHttpResult();
|
cancellationToken
|
||||||
}
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
private static async Task<IResult> CopyTemplate(
|
}
|
||||||
Guid channelId,
|
|
||||||
Guid targetChannelId,
|
private static async Task<IResult> RestoreTemplate(
|
||||||
ISender sender,
|
Guid channelId,
|
||||||
CancellationToken cancellationToken
|
ISender sender,
|
||||||
)
|
CancellationToken cancellationToken
|
||||||
{
|
)
|
||||||
var result = await sender.Send(
|
{
|
||||||
new CopyTemplateCommand(channelId, targetChannelId),
|
var result = await sender.Send(new RestoreTemplateCommand(channelId), cancellationToken);
|
||||||
cancellationToken
|
return result.ToHttpResult();
|
||||||
);
|
}
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
private static async Task<IResult> ResetTemplate(
|
||||||
|
Guid channelId,
|
||||||
private static async Task<IResult> UpdateTemplate(
|
ISender sender,
|
||||||
Guid templateId,
|
CancellationToken cancellationToken
|
||||||
UpdateTemplateBody body,
|
)
|
||||||
ISender sender,
|
{
|
||||||
CancellationToken cancellationToken
|
var result = await sender.Send(new ResetTemplateCommand(channelId), cancellationToken);
|
||||||
)
|
return result.ToHttpResult();
|
||||||
{
|
}
|
||||||
var result = await sender.Send(
|
|
||||||
new UpdateTemplateCommand(
|
private static async Task<IResult> PreviewTemplate(
|
||||||
templateId,
|
Guid channelId,
|
||||||
body.Name,
|
ISender sender,
|
||||||
body.FallbackGroupId,
|
CancellationToken cancellationToken,
|
||||||
body.DefaultJunctionId,
|
int days = 1
|
||||||
body.Rules
|
)
|
||||||
),
|
{
|
||||||
cancellationToken
|
var result = await sender.Send(
|
||||||
);
|
new PreviewScheduleQuery(channelId, days),
|
||||||
return result.ToHttpResult();
|
cancellationToken
|
||||||
}
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
private static async Task<IResult> CreateLayer(
|
}
|
||||||
Guid templateId,
|
|
||||||
CreateLayerBody body,
|
private static async Task<IResult> ValidateTemplate(
|
||||||
ISender sender,
|
Guid channelId,
|
||||||
CancellationToken cancellationToken
|
ISender sender,
|
||||||
)
|
CancellationToken cancellationToken
|
||||||
{
|
)
|
||||||
var result = await sender.Send(
|
{
|
||||||
new CreateLayerCommand(templateId, body.Name, body.Priority),
|
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
|
||||||
cancellationToken
|
return result.ToHttpResult();
|
||||||
);
|
}
|
||||||
return result.IsSuccess
|
|
||||||
? Results.Created(
|
private static async Task<IResult> DiffTemplate(
|
||||||
$"/api/admin/layers/{result.Value}",
|
Guid channelId,
|
||||||
new CreatedIdResponse(result.Value)
|
ISender sender,
|
||||||
)
|
CancellationToken cancellationToken
|
||||||
: result.ToHttpResult();
|
)
|
||||||
}
|
{
|
||||||
|
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
|
||||||
private static async Task<IResult> UpdateLayer(
|
return result.ToHttpResult();
|
||||||
Guid layerId,
|
}
|
||||||
UpdateLayerBody body,
|
|
||||||
ISender sender,
|
private static async Task<IResult> ListGridProfiles(
|
||||||
CancellationToken cancellationToken
|
ISender sender,
|
||||||
)
|
CancellationToken cancellationToken
|
||||||
{
|
)
|
||||||
var result = await sender.Send(
|
{
|
||||||
new UpdateLayerCommand(
|
var result = await sender.Send(new ListGridProfilesQuery(), cancellationToken);
|
||||||
layerId,
|
return result.ToHttpResult();
|
||||||
body.Name,
|
}
|
||||||
body.Priority,
|
|
||||||
body.Applicability,
|
private static async Task<IResult> PreviewGrid(
|
||||||
body.IsEnabled
|
Guid channelId,
|
||||||
),
|
GridProfileKind profile,
|
||||||
cancellationToken
|
GridGenerationMode mode,
|
||||||
);
|
ISender sender,
|
||||||
return result.ToHttpResult();
|
CancellationToken cancellationToken
|
||||||
}
|
)
|
||||||
|
{
|
||||||
private static async Task<IResult> DeleteLayer(
|
var result = await sender.Send(
|
||||||
Guid layerId,
|
new PreviewGeneratedGridQuery(channelId, profile, mode),
|
||||||
ISender sender,
|
cancellationToken
|
||||||
CancellationToken cancellationToken
|
);
|
||||||
)
|
return result.ToHttpResult();
|
||||||
{
|
}
|
||||||
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
|
|
||||||
return result.ToHttpResult();
|
private static async Task<IResult> GenerateGrid(
|
||||||
}
|
Guid channelId,
|
||||||
|
GenerateGridBody body,
|
||||||
private static async Task<IResult> CreateSlot(
|
ISender sender,
|
||||||
Guid layerId,
|
CancellationToken cancellationToken
|
||||||
SlotInput input,
|
)
|
||||||
ISender sender,
|
{
|
||||||
CancellationToken cancellationToken
|
var result = await sender.Send(
|
||||||
)
|
new GenerateGridCommand(channelId, body.Profile, body.Mode),
|
||||||
{
|
cancellationToken
|
||||||
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
|
);
|
||||||
return result.IsSuccess
|
return result.ToHttpResult();
|
||||||
? Results.Created(
|
}
|
||||||
$"/api/admin/slots/{result.Value}",
|
|
||||||
new CreatedIdResponse(result.Value)
|
private static async Task<IResult> ExportGrid(
|
||||||
)
|
Guid channelId,
|
||||||
: result.ToHttpResult();
|
ISender sender,
|
||||||
}
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
private static async Task<IResult> UpdateSlot(
|
{
|
||||||
Guid slotId,
|
var result = await sender.Send(new ExportGridQuery(channelId), cancellationToken);
|
||||||
SlotInput input,
|
return result.ToHttpResult();
|
||||||
ISender sender,
|
}
|
||||||
CancellationToken cancellationToken
|
|
||||||
)
|
private static async Task<IResult> ImportGrid(
|
||||||
{
|
Guid channelId,
|
||||||
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
|
ImportGridBody body,
|
||||||
return result.ToHttpResult();
|
ISender sender,
|
||||||
}
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
private static async Task<IResult> DeleteSlot(
|
{
|
||||||
Guid slotId,
|
var result = await sender.Send(
|
||||||
ISender sender,
|
new ImportGridCommand(channelId, body.Config, body.Replace),
|
||||||
CancellationToken cancellationToken
|
cancellationToken
|
||||||
)
|
);
|
||||||
{
|
return result.ToHttpResult();
|
||||||
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
|
}
|
||||||
return result.ToHttpResult();
|
|
||||||
}
|
private static async Task<IResult> BuildPrompt(
|
||||||
}
|
Guid channelId,
|
||||||
|
GridPromptBody body,
|
||||||
public sealed record UpdateTemplateBody(
|
ISender sender,
|
||||||
string Name,
|
CancellationToken cancellationToken
|
||||||
Guid? FallbackGroupId,
|
)
|
||||||
Guid? DefaultJunctionId,
|
{
|
||||||
PlanningRules? Rules
|
var result = await sender.Send(
|
||||||
);
|
new BuildGridPromptQuery(channelId, body.References ?? [], body.Notes),
|
||||||
|
cancellationToken
|
||||||
public sealed record CreateLayerBody(string Name, int Priority);
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
public sealed record UpdateLayerBody(
|
}
|
||||||
string Name,
|
|
||||||
int Priority,
|
private static async Task<IResult> CopyTemplate(
|
||||||
LayerApplicability? Applicability,
|
Guid channelId,
|
||||||
bool IsEnabled
|
Guid targetChannelId,
|
||||||
);
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new CopyTemplateCommand(channelId, targetChannelId),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateTemplate(
|
||||||
|
Guid templateId,
|
||||||
|
UpdateTemplateBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new UpdateTemplateCommand(
|
||||||
|
templateId,
|
||||||
|
body.Name,
|
||||||
|
body.FallbackGroupId,
|
||||||
|
body.DefaultJunctionId,
|
||||||
|
body.Rules
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateLayer(
|
||||||
|
Guid templateId,
|
||||||
|
CreateLayerBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new CreateLayerCommand(templateId, body.Name, body.Priority),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Created(
|
||||||
|
$"/api/admin/layers/{result.Value}",
|
||||||
|
new CreatedIdResponse(result.Value)
|
||||||
|
)
|
||||||
|
: result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateLayer(
|
||||||
|
Guid layerId,
|
||||||
|
UpdateLayerBody body,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(
|
||||||
|
new UpdateLayerCommand(
|
||||||
|
layerId,
|
||||||
|
body.Name,
|
||||||
|
body.Priority,
|
||||||
|
body.Applicability,
|
||||||
|
body.IsEnabled
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteLayer(
|
||||||
|
Guid layerId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateSlot(
|
||||||
|
Guid layerId,
|
||||||
|
SlotInput input,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
|
||||||
|
return result.IsSuccess
|
||||||
|
? Results.Created(
|
||||||
|
$"/api/admin/slots/{result.Value}",
|
||||||
|
new CreatedIdResponse(result.Value)
|
||||||
|
)
|
||||||
|
: result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdateSlot(
|
||||||
|
Guid slotId,
|
||||||
|
SlotInput input,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteSlot(
|
||||||
|
Guid slotId,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UpdateTemplateBody(
|
||||||
|
string Name,
|
||||||
|
Guid? FallbackGroupId,
|
||||||
|
Guid? DefaultJunctionId,
|
||||||
|
PlanningRules? Rules
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record GenerateGridBody(GridProfileKind Profile, GridGenerationMode Mode);
|
||||||
|
|
||||||
|
public sealed record ImportGridBody(GridConfig Config, bool Replace);
|
||||||
|
|
||||||
|
public sealed record GridPromptBody(IReadOnlyList<string>? References, string? Notes);
|
||||||
|
|
||||||
|
public sealed record CreateLayerBody(string Name, int Priority);
|
||||||
|
|
||||||
|
public sealed record UpdateLayerBody(
|
||||||
|
string Name,
|
||||||
|
int Priority,
|
||||||
|
LayerApplicability? Applicability,
|
||||||
|
bool IsEnabled
|
||||||
|
);
|
||||||
|
|||||||
+162
-148
@@ -1,148 +1,162 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Api.Endpoints;
|
using TeleWave.Api.Endpoints;
|
||||||
using TeleWave.Application;
|
using TeleWave.Application;
|
||||||
using TeleWave.Infrastructure;
|
using TeleWave.Infrastructure;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
using TeleWave.Infrastructure.Persistence;
|
using TeleWave.Infrastructure.Persistence;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
|
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
|
||||||
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
|
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
|
||||||
builder.WebHost.ConfigureKestrel(options =>
|
builder.WebHost.ConfigureKestrel(options =>
|
||||||
options.Limits.MaxRequestBodySize =
|
options.Limits.MaxRequestBodySize =
|
||||||
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
|
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
|
||||||
);
|
);
|
||||||
|
|
||||||
// Структурное логирование (Serilog), конфигурация из appsettings/env.
|
// Структурное логирование (Serilog), конфигурация из appsettings/env.
|
||||||
builder.Services.AddSerilog(
|
builder.Services.AddSerilog(
|
||||||
(services, configuration) =>
|
(services, configuration) =>
|
||||||
configuration
|
configuration
|
||||||
.ReadFrom.Configuration(builder.Configuration)
|
.ReadFrom.Configuration(builder.Configuration)
|
||||||
.ReadFrom.Services(services)
|
.ReadFrom.Services(services)
|
||||||
.Enrich.FromLogContext()
|
.Enrich.FromLogContext()
|
||||||
);
|
);
|
||||||
|
|
||||||
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
|
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
|
||||||
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
|
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
|
||||||
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
|
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
|
||||||
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
|
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
{
|
{
|
||||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
|
||||||
foreach (
|
foreach (var proxy in ReadList(builder.Configuration, "ForwardedHeaders:KnownProxies"))
|
||||||
var proxy in builder
|
options.KnownProxies.Add(IPAddress.Parse(proxy));
|
||||||
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
|
|
||||||
.Get<string[]>()
|
foreach (var network in ReadList(builder.Configuration, "ForwardedHeaders:KnownNetworks"))
|
||||||
?? []
|
{
|
||||||
)
|
var parts = network.Split('/');
|
||||||
options.KnownProxies.Add(IPAddress.Parse(proxy));
|
options.KnownIPNetworks.Add(
|
||||||
|
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
|
||||||
foreach (
|
);
|
||||||
var network in builder
|
}
|
||||||
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
|
|
||||||
.Get<string[]>()
|
// Список доверенных прокси/сетей приходит из .env одной строкой (env-переменная по одному ключу
|
||||||
?? []
|
// в string[] не биндится) — принимаем и запятые, и массив appsettings/индексы __0. Иначе
|
||||||
)
|
// задокументированная в .env.example запись через запятую молча игнорировалась бы, оставляя
|
||||||
{
|
// доверие только loopback и обрывая подхват X-Forwarded-Proto.
|
||||||
var parts = network.Split('/');
|
static string[] ReadList(IConfiguration config, string key)
|
||||||
options.KnownIPNetworks.Add(
|
{
|
||||||
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
|
var array = config.GetSection(key).Get<string[]>();
|
||||||
);
|
if (array is { Length: > 0 })
|
||||||
}
|
return array;
|
||||||
});
|
|
||||||
|
var scalar = config[key];
|
||||||
builder.Services.AddHttpContextAccessor();
|
return string.IsNullOrWhiteSpace(scalar)
|
||||||
builder.Services.AddSingleton<UploadLimits>();
|
? []
|
||||||
builder.Services.AddApplication();
|
: scalar.Split(
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
',',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
|
||||||
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
|
);
|
||||||
builder.Services.AddRateLimiter(options =>
|
}
|
||||||
{
|
});
|
||||||
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
|
|
||||||
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
|
builder.Services.AddHttpContextAccessor();
|
||||||
options.AddPolicy(
|
builder.Services.AddSingleton<UploadLimits>();
|
||||||
RateLimiting.AuthPolicy,
|
builder.Services.AddApplication();
|
||||||
httpContext =>
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
RateLimitPartition.GetFixedWindowLimiter(
|
|
||||||
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
|
||||||
_ => new FixedWindowRateLimiterOptions
|
builder.Services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
PermitLimit = authPermitLimit,
|
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
|
||||||
Window = TimeSpan.FromMinutes(1),
|
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
|
||||||
QueueLimit = 0,
|
options.AddPolicy(
|
||||||
}
|
RateLimiting.AuthPolicy,
|
||||||
)
|
httpContext =>
|
||||||
);
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
});
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
|
PermitLimit = authPermitLimit,
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
Window = TimeSpan.FromMinutes(1),
|
||||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
|
QueueLimit = 0,
|
||||||
);
|
}
|
||||||
|
)
|
||||||
builder.Services.AddProblemDetails();
|
);
|
||||||
builder.Services.AddOpenApi();
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||||
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
|
||||||
await app.Services.ApplyMigrationsAsync();
|
);
|
||||||
await app.Services.SeedDataAsync();
|
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
app.UseForwardedHeaders();
|
builder.Services.AddOpenApi();
|
||||||
app.UseSerilogRequestLogging();
|
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
|
||||||
app.UseExceptionHandler();
|
|
||||||
|
var app = builder.Build();
|
||||||
app.UseRateLimiter();
|
|
||||||
|
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
||||||
app.UseAuthentication();
|
await app.Services.ApplyMigrationsAsync();
|
||||||
app.UseAuthorization();
|
await app.Services.SeedDataAsync();
|
||||||
|
|
||||||
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
|
app.UseForwardedHeaders();
|
||||||
// не раскрывать полную карту эндпоинтов без необходимости.
|
app.UseSerilogRequestLogging();
|
||||||
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
|
app.UseExceptionHandler();
|
||||||
{
|
|
||||||
app.MapOpenApi();
|
app.UseRateLimiter();
|
||||||
app.MapScalarApiReference();
|
|
||||||
}
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
app.MapHealthChecks("/health");
|
|
||||||
|
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
|
||||||
app.MapAuthEndpoints();
|
// не раскрывать полную карту эндпоинтов без необходимости.
|
||||||
app.MapRoleEndpoints();
|
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
|
||||||
app.MapAdminUserEndpoints();
|
{
|
||||||
app.MapMediaEndpoints();
|
app.MapOpenApi();
|
||||||
app.MapShowEndpoints();
|
app.MapScalarApiReference();
|
||||||
app.MapGenreEndpoints();
|
}
|
||||||
app.MapInterstitialEndpoints();
|
|
||||||
app.MapCollectionEndpoints();
|
app.MapHealthChecks("/health");
|
||||||
app.MapGroupEndpoints();
|
|
||||||
app.MapTemplateEndpoints();
|
app.MapAuthEndpoints();
|
||||||
app.MapJunctionEndpoints();
|
app.MapRoleEndpoints();
|
||||||
app.MapChannelEndpoints();
|
app.MapAdminUserEndpoints();
|
||||||
app.MapStreamingEndpoints();
|
app.MapMediaEndpoints();
|
||||||
app.MapMaintenanceEndpoints();
|
app.MapShowEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
app.MapGenreEndpoints();
|
||||||
app.MapMetadataEndpoints();
|
app.MapInterstitialEndpoints();
|
||||||
app.MapImageEndpoints();
|
app.MapCollectionEndpoints();
|
||||||
|
app.MapGroupEndpoints();
|
||||||
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
app.MapTemplateEndpoints();
|
||||||
app.UseDefaultFiles();
|
app.MapBumperEndpoints();
|
||||||
app.UseStaticFiles();
|
app.MapJunctionEndpoints();
|
||||||
app.MapFallbackToFile("index.html");
|
app.MapChannelEndpoints();
|
||||||
|
app.MapTelegramEndpoints();
|
||||||
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
|
app.MapStreamingEndpoints();
|
||||||
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
|
app.MapIptvEndpoints();
|
||||||
await app.RunAsync();
|
app.MapMaintenanceEndpoints();
|
||||||
|
app.MapStorageEndpoints();
|
||||||
|
app.MapSettingsEndpoints();
|
||||||
|
app.MapMetadataEndpoints();
|
||||||
|
app.MapImageEndpoints();
|
||||||
|
app.MapDiagnosticsEndpoints();
|
||||||
|
|
||||||
|
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
|
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
|
||||||
|
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
|
||||||
|
await app.RunAsync();
|
||||||
|
|||||||
@@ -20,5 +20,10 @@
|
|||||||
},
|
},
|
||||||
"Storage": {
|
"Storage": {
|
||||||
"RootPath": ".dev-media"
|
"RootPath": ".dev-media"
|
||||||
|
},
|
||||||
|
// Отладочные дампы каналов ложатся в папку репозитория: разбирать «почему сетка построилась
|
||||||
|
// так» приходится рядом с кодом. Путь относительный — от рабочего каталога Api (backend/src/TeleWave.Api).
|
||||||
|
"Debug": {
|
||||||
|
"ExportDirectory": "../../../debug-exports"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>>;
|
|
||||||
-34
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
public sealed class AddBumperVariantCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<AddBumperVariantCommand, Result<Guid>>
|
||||||
|
{
|
||||||
|
public async Task<Result<Guid>> Handle(
|
||||||
|
AddBumperVariantCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
|
dbContext,
|
||||||
|
command.TemplateId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (template is null)
|
||||||
|
return Result.Failure<Guid>(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
|
var variant = template.AddVariant(command.Name);
|
||||||
|
variant.SetLines(BumperTemplateLoader.DefaultLines());
|
||||||
|
return Result.Success(variant.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
public sealed record ListBumperTemplatesQuery : IQuery<IReadOnlyList<BumperTemplateDto>>;
|
||||||
|
|
||||||
|
public sealed record CreateBumperTemplateCommand(string Name) : ICommand<Result<Guid>>;
|
||||||
|
|
||||||
|
/// <summary>Оформление блока: имя, шрифт и палитра. Меняет ревизию — заставки пересобираются.</summary>
|
||||||
|
public sealed record UpdateBumperTemplateCommand(Guid TemplateId, BumperStyle Style)
|
||||||
|
: ICommand<Result>;
|
||||||
|
|
||||||
|
public sealed record DeleteBumperTemplateCommand(Guid TemplateId) : ICommand<Result>;
|
||||||
|
|
||||||
|
public sealed record SetBumperTemplateAudioCommand(
|
||||||
|
Guid TemplateId,
|
||||||
|
string Extension,
|
||||||
|
double DurationSeconds
|
||||||
|
) : ICommand<Result>;
|
||||||
|
|
||||||
|
public sealed record ClearBumperTemplateAudioCommand(Guid TemplateId) : ICommand<Result>;
|
||||||
|
|
||||||
|
public sealed record SetBumperTemplateBackgroundCommand(Guid TemplateId, Guid ImageId)
|
||||||
|
: ICommand<Result>;
|
||||||
|
|
||||||
|
public sealed record ClearBumperTemplateBackgroundCommand(Guid TemplateId) : ICommand<Result>;
|
||||||
|
|
||||||
|
public sealed record AddBumperVariantCommand(Guid TemplateId, string Name) : ICommand<Result<Guid>>;
|
||||||
|
|
||||||
|
/// <summary>Полное содержимое подблока — строки редактор всегда присылает списком целиком.</summary>
|
||||||
|
public sealed record BumperVariantInput(
|
||||||
|
string Name,
|
||||||
|
BumperTrigger Trigger,
|
||||||
|
BumperBackground Background,
|
||||||
|
int Weight,
|
||||||
|
IReadOnlyList<BumperLineDto> Lines,
|
||||||
|
/// <summary>Ролик-подложка для фона <see cref="BumperBackground.Clip"/>.</summary>
|
||||||
|
Guid? BackgroundClipShowId = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record UpdateBumperVariantCommand(
|
||||||
|
Guid TemplateId,
|
||||||
|
Guid VariantId,
|
||||||
|
BumperVariantInput Input
|
||||||
|
) : ICommand<Result>;
|
||||||
|
|
||||||
|
public sealed record RemoveBumperVariantCommand(Guid TemplateId, Guid VariantId) : ICommand<Result>;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Рендер примера заставки. Канал нужен только для образцов подстановки: блок общий, но посмотреть
|
||||||
|
/// его надо глазами конкретного канала — иначе <c>{channel}</c> не на что заменить.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RenderBumperPreviewCommand(Guid TemplateId, Guid? ChannelId)
|
||||||
|
: ICommand<Result>;
|
||||||
|
|
||||||
|
public sealed class CreateBumperTemplateCommandValidator
|
||||||
|
: AbstractValidator<CreateBumperTemplateCommand>
|
||||||
|
{
|
||||||
|
public CreateBumperTemplateCommandValidator() =>
|
||||||
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateBumperTemplateCommandValidator
|
||||||
|
: AbstractValidator<UpdateBumperTemplateCommand>
|
||||||
|
{
|
||||||
|
public UpdateBumperTemplateCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Style.Name).NotEmpty().MaximumLength(64);
|
||||||
|
RuleFor(x => x.Style.BackgroundColor).NotEmpty().MaximumLength(32);
|
||||||
|
RuleFor(x => x.Style.BackgroundColor2).NotEmpty().MaximumLength(32);
|
||||||
|
RuleFor(x => x.Style.AccentColor).NotEmpty().MaximumLength(32);
|
||||||
|
RuleFor(x => x.Style.TextColor).NotEmpty().MaximumLength(32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AddBumperVariantCommandValidator : AbstractValidator<AddBumperVariantCommand>
|
||||||
|
{
|
||||||
|
public AddBumperVariantCommandValidator() => RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateBumperVariantCommandValidator
|
||||||
|
: AbstractValidator<UpdateBumperVariantCommand>
|
||||||
|
{
|
||||||
|
/// <summary>Больше шести строк в кадр не помещается ни при каком размере шрифта.</summary>
|
||||||
|
private const int MaxLines = 6;
|
||||||
|
|
||||||
|
public UpdateBumperVariantCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Input.Name).NotEmpty().MaximumLength(64);
|
||||||
|
RuleFor(x => x.Input.Weight).InclusiveBetween(0, 1000);
|
||||||
|
RuleFor(x => x.Input.Lines).NotNull().Must(l => l.Count <= MaxLines);
|
||||||
|
RuleForEach(x => x.Input.Lines)
|
||||||
|
.ChildRules(line => line.RuleFor(l => l.Text).NotEmpty().MaximumLength(120));
|
||||||
|
|
||||||
|
// Фон из ролика без самого ролика — это не «пока не выбрал», а заставка, которая молча
|
||||||
|
// отрендерится градиентом. Пусть выбор будет обязательным сразу.
|
||||||
|
RuleFor(x => x.Input.BackgroundClipShowId)
|
||||||
|
.NotNull()
|
||||||
|
.When(x => x.Input.Background == BumperBackground.Clip);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
/// <summary>Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами.</summary>
|
||||||
|
public sealed record BumperLineDto(BumperLineStyle Style, BumperLineColor Color, string Text);
|
||||||
|
|
||||||
|
/// <summary>Подблок (текст-вариант): свои строки, фон и правило показа поверх оформления блока.</summary>
|
||||||
|
public sealed record BumperTextVariantDto(
|
||||||
|
Guid Id,
|
||||||
|
int Position,
|
||||||
|
string Name,
|
||||||
|
BumperTrigger Trigger,
|
||||||
|
BumperBackground Background,
|
||||||
|
int Weight,
|
||||||
|
IReadOnlyList<BumperLineDto> Lines,
|
||||||
|
/// <summary>Ролик-подложка для фона <see cref="BumperBackground.Clip"/>.</summary>
|
||||||
|
Guid? BackgroundClipShowId
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Блок заставки: оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
|
||||||
|
public sealed record BumperTemplateDto(
|
||||||
|
Guid Id,
|
||||||
|
string Name,
|
||||||
|
BumperFont Font,
|
||||||
|
string BackgroundColor,
|
||||||
|
string BackgroundColor2,
|
||||||
|
string AccentColor,
|
||||||
|
string TextColor,
|
||||||
|
Guid? BackgroundImageId,
|
||||||
|
bool HasAudio,
|
||||||
|
double? AudioDurationSeconds,
|
||||||
|
/// <summary>Во скольких врезках стыков используется блок — блоки общие, и это надо видеть до правки.</summary>
|
||||||
|
int UsageCount,
|
||||||
|
IReadOnlyList<BumperTextVariantDto> Variants
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
/// <summary>Ошибки блоков заставок. Блоки общие для всех каналов, поэтому и каталог свой, не канальный.</summary>
|
||||||
|
public static class BumperErrors
|
||||||
|
{
|
||||||
|
public static readonly Error TemplateNotFound = Error.NotFound(
|
||||||
|
"Bumpers.TemplateNotFound",
|
||||||
|
"Блок заставки не найден."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error VariantNotFound = Error.NotFound(
|
||||||
|
"Bumpers.VariantNotFound",
|
||||||
|
"Подблок заставки не найден."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error CannotRemoveLastVariant = Error.Validation(
|
||||||
|
"Bumpers.CannotRemoveLastVariant",
|
||||||
|
"Нельзя удалить последний подблок — нужен хотя бы один."
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Блок общий: держат его врезки конкретных стыков, их и перечисляем.</summary>
|
||||||
|
public static Error TemplateInUse(string where) =>
|
||||||
|
Error.Conflict(
|
||||||
|
"Bumpers.TemplateInUse",
|
||||||
|
where.Length > 0
|
||||||
|
? $"Блок заставки используется во врезках стыков ({where}) — уберите его оттуда."
|
||||||
|
: "Блок заставки используется во врезках стыков."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error VariantInUse = Error.Conflict(
|
||||||
|
"Bumpers.VariantInUse",
|
||||||
|
"Подблок заставки выбран во врезке стыка."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error InvalidFile = Error.Validation(
|
||||||
|
"Bumpers.InvalidFile",
|
||||||
|
"Недопустимый файл заставки (формат или размер)."
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Неизвестный плейсхолдер — ошибка ввода: в эфире его бы уже никто не заметил.</summary>
|
||||||
|
public static Error UnknownPlaceholders(IEnumerable<string> tokens) =>
|
||||||
|
Error.Validation(
|
||||||
|
"Bumpers.UnknownPlaceholders",
|
||||||
|
$"Неизвестные плейсхолдеры: {string.Join(", ", tokens.Select(t => $"{{{t}}}"))}."
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
/// <summary>Ручной маппинг блока заставки в DTO — одна точка на список и на карточку.</summary>
|
||||||
|
public static class BumperMapper
|
||||||
|
{
|
||||||
|
public static BumperTemplateDto ToDto(BumperTemplate template, int usageCount) =>
|
||||||
|
new(
|
||||||
|
template.Id,
|
||||||
|
template.Name,
|
||||||
|
template.Font,
|
||||||
|
template.BackgroundColor,
|
||||||
|
template.BackgroundColor2,
|
||||||
|
template.AccentColor,
|
||||||
|
template.TextColor,
|
||||||
|
template.BackgroundImageId,
|
||||||
|
template.AudioExtension is not null,
|
||||||
|
template.AudioDurationSeconds,
|
||||||
|
usageCount,
|
||||||
|
template
|
||||||
|
.Variants.OrderBy(v => v.Position)
|
||||||
|
.Select(v => new BumperTextVariantDto(
|
||||||
|
v.Id,
|
||||||
|
v.Position,
|
||||||
|
v.Name,
|
||||||
|
v.Trigger,
|
||||||
|
v.Background,
|
||||||
|
v.Weight,
|
||||||
|
v.Lines.OrderBy(l => l.Position)
|
||||||
|
.Select(l => new BumperLineDto(l.Style, l.Color, l.Text))
|
||||||
|
.ToList(),
|
||||||
|
v.BackgroundClipShowId
|
||||||
|
))
|
||||||
|
.ToList()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Чем подставляются плейсхолдеры одной заставки. Собирает контекст планировщик — только он знает
|
||||||
|
/// и пару соседей, и точное время показа; редактор подставляет те же поля образцами.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BumperContext(
|
||||||
|
string ChannelName,
|
||||||
|
int? ChannelNumber,
|
||||||
|
/// <summary>Момент показа заставки во времени канала.</summary>
|
||||||
|
DateTimeOffset LocalMoment,
|
||||||
|
string? NowTitle = null,
|
||||||
|
string? NextTitle = null,
|
||||||
|
string? NowEpisode = null,
|
||||||
|
string? NextEpisode = null,
|
||||||
|
int? NextYear = null,
|
||||||
|
string? NextGenre = null,
|
||||||
|
/// <summary>Во сколько начнётся следующая программа (время канала).</summary>
|
||||||
|
TimeOnly? NextTime = null,
|
||||||
|
string? SlotTitle = null,
|
||||||
|
/// <summary>Что и когда идёт вечером этих вещательных суток — для анонсов.</summary>
|
||||||
|
BumperAnnouncement? Tonight = null,
|
||||||
|
BumperAnnouncement? Tomorrow = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Анонсируемая программа: что и во сколько. Null — анонсировать нечего, строка схлопнется.</summary>
|
||||||
|
public sealed record BumperAnnouncement(string Title, TimeOnly Time);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Плейсхолдеры текста заставки: «ДАЛЕЕ В {next.time}» → «ДАЛЕЕ В 21:30».
|
||||||
|
///
|
||||||
|
/// Список закрытый и проверяется при сохранении: незнакомый плейсхолдер — ошибка ввода, а не
|
||||||
|
/// сюрприз в эфире, где его уже не увидит никто, кроме зрителя.
|
||||||
|
/// </summary>
|
||||||
|
public static partial class BumperPlaceholders
|
||||||
|
{
|
||||||
|
/// <summary>Все допустимые имена. Описания и образцы живут в локалях редактора, не здесь.</summary>
|
||||||
|
public static readonly IReadOnlySet<string> Tokens = new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"channel",
|
||||||
|
"channel.number",
|
||||||
|
"now.title",
|
||||||
|
"next.title",
|
||||||
|
"now.episode",
|
||||||
|
"next.episode",
|
||||||
|
"next.year",
|
||||||
|
"next.genre",
|
||||||
|
"next.time",
|
||||||
|
"tonight.title",
|
||||||
|
"tonight.time",
|
||||||
|
"tomorrow.title",
|
||||||
|
"tomorrow.time",
|
||||||
|
"time",
|
||||||
|
"date",
|
||||||
|
"weekday",
|
||||||
|
"slot",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Плейсхолдеры, привязанные к моменту показа. Каждое их значение уникально, поэтому кэш
|
||||||
|
/// отрендеренных заставок с ними перестаёт работать — редактор обязан об этом предупредить.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly IReadOnlySet<string> VolatileTokens = new HashSet<string>(
|
||||||
|
StringComparer.Ordinal
|
||||||
|
)
|
||||||
|
{
|
||||||
|
"time",
|
||||||
|
"date",
|
||||||
|
"weekday",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Русская культура фиксирована: заставка рендерится один раз в видео, локали зрителя у неё нет.
|
||||||
|
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");
|
||||||
|
|
||||||
|
/// <summary>Время в кадре — только часы и минуты: секунды в заставке не читаются.</summary>
|
||||||
|
private const string TimeFormat = "HH:mm";
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{([a-zA-Z][a-zA-Z.]*)\}", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex TokenPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"[ \t]{2,}", RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex ExtraSpaces();
|
||||||
|
|
||||||
|
/// <summary>Разделители, которые остаются висеть, когда подставлять оказалось нечего.</summary>
|
||||||
|
private static readonly char[] DanglingSeparators = ['—', '–', '-', ':', '·', ',', ';', '/'];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подставляет значения. Неизвестное значение даёт пустую строку, а осиротевшие разделители
|
||||||
|
/// по краям убираются: «СЕЙЧАС — {now.title}» без текущей программы должно стать «СЕЙЧАС»,
|
||||||
|
/// а не «СЕЙЧАС —».
|
||||||
|
///
|
||||||
|
/// Строка, в которой не подставился ни один плейсхолдер, исчезает целиком: она и писалась ради
|
||||||
|
/// данных, а «ДАЛЕЕ В» без времени — это не подпись, а мусор в кадре. Строки без плейсхолдеров
|
||||||
|
/// это не касается: они и есть постоянный текст.
|
||||||
|
/// </summary>
|
||||||
|
public static string Resolve(string text, BumperContext context)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var tokens = TokenPattern().Matches(text);
|
||||||
|
if (tokens.Count == 0)
|
||||||
|
return Clean(text);
|
||||||
|
|
||||||
|
// Собираем строку сами, а не через Replace с лямбдой: подстановку надо не только сделать,
|
||||||
|
// но и сосчитать, а счётчик в замыкании читается хуже и не виден анализаторам.
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
var cursor = 0;
|
||||||
|
var filled = 0;
|
||||||
|
|
||||||
|
foreach (Match token in tokens)
|
||||||
|
{
|
||||||
|
builder.Append(text, cursor, token.Index - cursor);
|
||||||
|
cursor = token.Index + token.Length;
|
||||||
|
|
||||||
|
var value = Value(token.Groups[1].Value, context);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
builder.Append(value);
|
||||||
|
filled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled == 0)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
builder.Append(text, cursor, text.Length - cursor);
|
||||||
|
return Clean(builder.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Схлопывает лишние пробелы и снимает осиротевшие разделители по краям.</summary>
|
||||||
|
private static string Clean(string text) =>
|
||||||
|
ExtraSpaces().Replace(text, " ").Trim().Trim(DanglingSeparators).Trim();
|
||||||
|
|
||||||
|
/// <summary>Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить.</summary>
|
||||||
|
public static IReadOnlySet<string> TokensIn(IEnumerable<string> texts)
|
||||||
|
{
|
||||||
|
var matches = texts
|
||||||
|
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||||
|
.SelectMany(text => TokenPattern().Matches(text))
|
||||||
|
.Select(match => match.Groups[1].Value);
|
||||||
|
|
||||||
|
return new HashSet<string>(matches, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Плейсхолдеры текста, которых нет в списке допустимых.</summary>
|
||||||
|
public static IReadOnlyList<string> UnknownTokens(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return TokenPattern()
|
||||||
|
.Matches(text)
|
||||||
|
.Select(m => m.Groups[1].Value)
|
||||||
|
.Where(token => !Tokens.Contains(token))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Есть ли в тексте плейсхолдер, привязанный к моменту показа (ломает кэш рендера).</summary>
|
||||||
|
public static bool IsVolatile(string? text) =>
|
||||||
|
!string.IsNullOrWhiteSpace(text)
|
||||||
|
&& TokenPattern().Matches(text).Any(m => VolatileTokens.Contains(m.Groups[1].Value));
|
||||||
|
|
||||||
|
private static string? Value(string token, BumperContext context) =>
|
||||||
|
token switch
|
||||||
|
{
|
||||||
|
"channel" => context.ChannelName,
|
||||||
|
"channel.number" => context.ChannelNumber?.ToString(Culture),
|
||||||
|
"now.title" => context.NowTitle,
|
||||||
|
"next.title" => context.NextTitle,
|
||||||
|
"now.episode" => context.NowEpisode,
|
||||||
|
"next.episode" => context.NextEpisode,
|
||||||
|
"next.year" => context.NextYear?.ToString(Culture),
|
||||||
|
"next.genre" => context.NextGenre,
|
||||||
|
"next.time" => context.NextTime?.ToString(TimeFormat, Culture),
|
||||||
|
"tonight.title" => context.Tonight?.Title,
|
||||||
|
"tonight.time" => context.Tonight?.Time.ToString(TimeFormat, Culture),
|
||||||
|
"tomorrow.title" => context.Tomorrow?.Title,
|
||||||
|
"tomorrow.time" => context.Tomorrow?.Time.ToString(TimeFormat, Culture),
|
||||||
|
"time" => TimeOnly
|
||||||
|
.FromDateTime(context.LocalMoment.DateTime)
|
||||||
|
.ToString(TimeFormat, Culture),
|
||||||
|
"date" => context.LocalMoment.ToString("d MMMM", Culture),
|
||||||
|
"weekday" => context.LocalMoment.ToString("dddd", Culture),
|
||||||
|
"slot" => context.SlotTitle,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Подпись серии в эфирном виде: «с5э12», либо просто номер, если сезон не распознан.</summary>
|
||||||
|
public static string? Episode(int? season, int? episode)
|
||||||
|
{
|
||||||
|
if (season is { } s and > 0 && episode is { } e and > 0)
|
||||||
|
return string.Create(Culture, $"с{s}э{e}");
|
||||||
|
return episode is { } only and > 0 ? string.Create(Culture, $"э{only}") : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сериализация готовых строк заставки в кэш-запись. Хранить подставленный текст обязательно:
|
||||||
|
/// время показа и пара соседей из ссылок задним числом не восстанавливаются, а ffmpeg запускается
|
||||||
|
/// фоновым сервисом уже после того, как лента записана.
|
||||||
|
/// </summary>
|
||||||
|
public static class BumperRenderedText
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string ToJson(IReadOnlyList<BumperRenderLine> lines) =>
|
||||||
|
JsonSerializer.Serialize(lines, Options);
|
||||||
|
|
||||||
|
public static IReadOnlyList<BumperRenderLine> FromJson(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<BumperRenderLine>>(json, Options) ?? [];
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,22 +4,18 @@ using TeleWave.Domain.Broadcast;
|
|||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (пути к постеру/фону/звуку,
|
/// Чистая сборка <see cref="BumperRenderSpec"/> из уже разрешённых входов (готовые строки, пути к
|
||||||
/// названия шоу). Общая точка для фонового рендерера заставок расписания и превью в админке.
|
/// постеру/фону/звуку). Общая точка для фонового рендерера заставок расписания и превью в админке.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BumperSpecFactory
|
public static class BumperSpecFactory
|
||||||
{
|
{
|
||||||
public static BumperRenderSpec Build(
|
public static BumperRenderSpec Build(
|
||||||
BumperOptions bumper,
|
BumperOptions bumper,
|
||||||
BumperFont font,
|
|
||||||
BumperTemplate template,
|
BumperTemplate template,
|
||||||
BumperTextVariant variant,
|
|
||||||
int alignedDurationSeconds,
|
int alignedDurationSeconds,
|
||||||
BumperSpecInputs inputs
|
BumperSpecInputs inputs
|
||||||
)
|
) =>
|
||||||
{
|
new(
|
||||||
var free = variant.Kind == BumperTextKind.Free;
|
|
||||||
return new BumperRenderSpec(
|
|
||||||
alignedDurationSeconds,
|
alignedDurationSeconds,
|
||||||
bumper.Width,
|
bumper.Width,
|
||||||
bumper.Height,
|
bumper.Height,
|
||||||
@@ -27,17 +23,14 @@ public static class BumperSpecFactory
|
|||||||
template.BackgroundColor2,
|
template.BackgroundColor2,
|
||||||
template.AccentColor,
|
template.AccentColor,
|
||||||
template.TextColor,
|
template.TextColor,
|
||||||
font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
|
template.Font == BumperFont.Serif ? bumper.FontFileSerif : bumper.FontFileSans,
|
||||||
free ? "" : variant.NowLabel,
|
inputs.Lines,
|
||||||
free ? "" : inputs.FromName,
|
|
||||||
free ? "" : variant.NextLabel,
|
|
||||||
free ? "" : inputs.ToName,
|
|
||||||
inputs.BackgroundAbsolutePath,
|
inputs.BackgroundAbsolutePath,
|
||||||
inputs.AudioPath,
|
inputs.AudioPath,
|
||||||
inputs.PosterAbsolutePath,
|
inputs.PosterAbsolutePath,
|
||||||
free,
|
inputs.ClipAbsolutePath,
|
||||||
variant.Line1,
|
// Джингл назначен — он и звучит, подрезанный под ролик; не назначен — играет
|
||||||
variant.Line2
|
// оригинальная дорожка ролика.
|
||||||
|
UseClipAudio: inputs.ClipAbsolutePath is not null && inputs.AudioPath is null
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Уже разрешённые входы рендера заставки: названия шоу «из/в» и пути к файлам. Разрешает их
|
/// Уже разрешённые входы рендера заставки: готовые строки (плейсхолдеры подставлены) и пути к
|
||||||
/// вызывающий (генератор эфира — по реальной паре соседей, превью — по образцам канала), а
|
/// файлам. Разрешает их вызывающий — генератор эфира по реальной паре соседей, редактор по
|
||||||
/// <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
|
/// образцам, — а <see cref="BumperSpecFactory"/> только раскладывает их по спецификации.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record BumperSpecInputs(
|
public sealed record BumperSpecInputs(
|
||||||
string FromName,
|
IReadOnlyList<BumperRenderLine> Lines,
|
||||||
string ToName,
|
|
||||||
string? AudioPath = null,
|
string? AudioPath = null,
|
||||||
/// <summary>Постер «следующего» шоу как фон; в превью не подставляется — шоу ещё неизвестно.</summary>
|
/// <summary>Постер шоу как фон; подставляется, только если подблок его запросил.</summary>
|
||||||
string? PosterAbsolutePath = null,
|
string? PosterAbsolutePath = null,
|
||||||
string? BackgroundAbsolutePath = null
|
string? BackgroundAbsolutePath = null,
|
||||||
|
/// <summary>Плейлист ролика-подложки, если текст пишется поверх готового видео.</summary>
|
||||||
|
string? ClipAbsolutePath = null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ using TeleWave.Application.Broadcast.Scheduling;
|
|||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил только
|
/// Восстанавливает <see cref="BumperRenderSpec"/> по кэш-строке заставки: планировщик сохранил
|
||||||
/// ссылки (канал/блок/подблок/пара шоу), а рендеру нужны названия шоу и абсолютные пути к звуку,
|
/// готовые строки и ссылку на блок, а рендеру нужны ещё абсолютные пути к звуку, постеру и фону.
|
||||||
/// постеру и фону. Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения,
|
/// Вынесено из фонового рендерера: чтение и сборка — работа слоя приложения, воркер лишь крутит ffmpeg.
|
||||||
/// воркер лишь крутит ffmpeg.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BumperSpecLoader(
|
public sealed class BumperSpecLoader(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IBumperTemplateStorage bumperStorage,
|
IBumperTemplateStorage bumperStorage,
|
||||||
IImageStore imageStore,
|
IImageStore imageStore,
|
||||||
|
IMediaStorage mediaStorage,
|
||||||
IOptions<BumperOptions> bumperOptions,
|
IOptions<BumperOptions> bumperOptions,
|
||||||
IOptions<StreamingOptions> streamingOptions
|
IOptions<StreamingOptions> streamingOptions
|
||||||
)
|
)
|
||||||
@@ -38,50 +39,63 @@ public sealed class BumperSpecLoader(
|
|||||||
if (cache is null)
|
if (cache is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var channel = await dbContext
|
var template = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.BumperTemplates.AsNoTracking()
|
||||||
.Include(c => c.BumperTemplates)
|
.FirstOrDefaultAsync(t => t.Id == cache.TemplateId, cancellationToken);
|
||||||
.ThenInclude(t => t.Variants)
|
if (template is null)
|
||||||
.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;
|
return null;
|
||||||
|
|
||||||
var names = await dbContext
|
var posterPath = cache.PosterShowId is { } showId
|
||||||
.Shows.AsNoTracking()
|
? await ResolveShowPosterAsync(showId, cancellationToken)
|
||||||
.Where(s => s.Id == cache.FromShowId || s.Id == cache.ToShowId)
|
: null;
|
||||||
.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 bgPath = await ResolveImagePathAsync(template.BackgroundImageId, cancellationToken);
|
||||||
|
var (clipPath, clipSeconds) = await ResolveClipAsync(
|
||||||
|
cache.BackgroundAssetId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
// Длину задаёт ролик, если он есть: подрезать чужую заставку под джингл значило бы обрывать
|
||||||
|
// её на полуслове. Выравнивание по сегменту остаётся — лента этого требует от любой записи.
|
||||||
var aligned = BumperDuration.Aligned(
|
var aligned = BumperDuration.Aligned(
|
||||||
BumperDuration.TemplateSeconds(template),
|
clipSeconds ?? BumperDuration.TemplateSeconds(template),
|
||||||
_segmentSeconds
|
_segmentSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
return BumperSpecFactory.Build(
|
return BumperSpecFactory.Build(
|
||||||
_bumper,
|
_bumper,
|
||||||
channel.BumperFont,
|
|
||||||
template,
|
template,
|
||||||
variant,
|
|
||||||
aligned,
|
aligned,
|
||||||
new BumperSpecInputs(
|
new BumperSpecInputs(
|
||||||
names.GetValueOrDefault(cache.FromShowId, "…"),
|
BumperRenderedText.FromJson(cache.RenderedLinesJson),
|
||||||
names.GetValueOrDefault(cache.ToShowId, "…"),
|
|
||||||
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
bumperStorage.AudioPath(template.Id, template.AudioExtension),
|
||||||
posterPath,
|
posterPath,
|
||||||
bgPath
|
bgPath,
|
||||||
|
clipPath
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Плейлист ролика-подложки и его длительность; null — ролика уже нет или он не готов.</summary>
|
||||||
|
private async Task<(string? Path, int? Seconds)> ResolveClipAsync(
|
||||||
|
Guid? assetId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (assetId is not { } id)
|
||||||
|
return (null, null);
|
||||||
|
|
||||||
|
var duration = await dbContext
|
||||||
|
.MediaAssets.AsNoTracking()
|
||||||
|
.Where(a => a.Id == id && a.Status == MediaAssetStatus.Ready)
|
||||||
|
.Select(a => a.Duration)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (duration is not { } value || value <= TimeSpan.Zero)
|
||||||
|
return (null, null);
|
||||||
|
|
||||||
|
var path = mediaStorage.ResolvePlaylistPath(id);
|
||||||
|
return path is null ? (null, null) : (path, (int)Math.Ceiling(value.TotalSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string?> ResolveShowPosterAsync(
|
private async Task<string?> ResolveShowPosterAsync(
|
||||||
Guid showId,
|
Guid showId,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
/// <summary>Общее для команд блока заставки: загрузка вместе с подблоками и строками.</summary>
|
||||||
|
internal static class BumperTemplateLoader
|
||||||
|
{
|
||||||
|
public static Task<BumperTemplate?> LoadAsync(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
Guid templateId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
) =>
|
||||||
|
dbContext
|
||||||
|
.BumperTemplates.Include(t => t.Variants)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Строки подблока «Сейчас / Далее» — с них начинается новый блок.</summary>
|
||||||
|
public static IReadOnlyList<BumperLine> DefaultLines() =>
|
||||||
|
[
|
||||||
|
BumperLine.Create(0, BumperLineStyle.Label, BumperLineColor.Accent, "СЕЙЧАС"),
|
||||||
|
BumperLine.Create(1, BumperLineStyle.Title, BumperLineColor.Text, "{now.title}"),
|
||||||
|
BumperLine.Create(2, BumperLineStyle.Label, BumperLineColor.Accent, "ДАЛЕЕ"),
|
||||||
|
BumperLine.Create(3, BumperLineStyle.Title, BumperLineColor.Text, "{next.title}"),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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>;
|
|
||||||
+7
-11
@@ -1,5 +1,4 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
@@ -15,19 +14,16 @@ public sealed class ClearBumperTemplateAudioCommandHandler(
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
dbContext,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
command.TemplateId,
|
||||||
if (channel is null)
|
cancellationToken
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
);
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
template.ClearAudio();
|
template.ClearAudio();
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await storage.DeleteAudioAsync(template.Id, cancellationToken);
|
||||||
await storage.DeleteAudioAsync(command.TemplateId, cancellationToken);
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>Удалить загруженную фон-картинку блока (вернуться к градиенту/постеру).</summary>
|
|
||||||
public sealed record ClearBumperTemplateBackgroundCommand(Guid ChannelId, Guid TemplateId)
|
|
||||||
: ICommand<Result>;
|
|
||||||
+6
-10
@@ -1,5 +1,4 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
@@ -13,17 +12,14 @@ public sealed class ClearBumperTemplateBackgroundCommandHandler(IAppDbContext db
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
dbContext,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
command.TemplateId,
|
||||||
if (channel is null)
|
cancellationToken
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
);
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
// Отвязываем фон; сама картинка остаётся в галерее.
|
|
||||||
template.ClearBackgroundImage();
|
template.ClearBackgroundImage();
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
public sealed class CreateBumperTemplateCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<CreateBumperTemplateCommand, Result<Guid>>
|
||||||
|
{
|
||||||
|
private const string DefaultVariantName = "Текст 1";
|
||||||
|
|
||||||
|
public Task<Result<Guid>> Handle(
|
||||||
|
CreateBumperTemplateCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var template = BumperTemplate.Create(command.Name, DefaultVariantName);
|
||||||
|
// Пустой блок в редакторе выглядит поломанным — новый начинается с «Сейчас / Далее».
|
||||||
|
template.Variants[0].SetLines(BumperTemplateLoader.DefaultLines());
|
||||||
|
dbContext.BumperTemplates.Add(template);
|
||||||
|
return Task.FromResult(Result.Success(template.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
public sealed class DeleteBumperTemplateCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IBumperTemplateStorage storage
|
||||||
|
) : ICommandHandler<DeleteBumperTemplateCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(
|
||||||
|
DeleteBumperTemplateCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
|
dbContext,
|
||||||
|
command.TemplateId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (template is null)
|
||||||
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
|
// Блок общий: удалив используемый, мы бы молча выключили заставки в чужих каналах.
|
||||||
|
var used = await dbContext.JunctionElements.AnyAsync(
|
||||||
|
e => e.BumperTemplateId == template.Id,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (used)
|
||||||
|
{
|
||||||
|
// Имя стыка — по связи врезки с ним; факт использования проверен отдельно и раньше.
|
||||||
|
var junctions = await (
|
||||||
|
from element in dbContext.JunctionElements
|
||||||
|
join junction in dbContext.JunctionTemplates
|
||||||
|
on element.JunctionTemplateId equals junction.Id
|
||||||
|
where element.BumperTemplateId == template.Id
|
||||||
|
select junction.Name
|
||||||
|
)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Failure(
|
||||||
|
BumperErrors.TemplateInUse(UsageText.Part("стыки", junctions) ?? string.Empty)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.BumperTemplates.Remove(template);
|
||||||
|
await storage.DeleteAudioAsync(template.Id, cancellationToken);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
public sealed class ListBumperTemplatesQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<ListBumperTemplatesQuery, IReadOnlyList<BumperTemplateDto>>
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<BumperTemplateDto>> Handle(
|
||||||
|
ListBumperTemplatesQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var templates = await dbContext
|
||||||
|
.BumperTemplates.AsNoTracking()
|
||||||
|
.Include(t => t.Variants)
|
||||||
|
.OrderBy(t => t.Name)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Блоки общие, поэтому «сколько врезок на меня ссылается» — не справка, а условие правки.
|
||||||
|
var usage = await dbContext
|
||||||
|
.JunctionElements.AsNoTracking()
|
||||||
|
.Where(e => e.Kind == JunctionElementKind.Bumper && e.BumperTemplateId != null)
|
||||||
|
.GroupBy(e => e.BumperTemplateId!.Value)
|
||||||
|
.Select(g => new { TemplateId = g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.TemplateId, x => x.Count, cancellationToken);
|
||||||
|
|
||||||
|
return templates.Select(t => BumperMapper.ToDto(t, usage.GetValueOrDefault(t.Id))).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>Удалить блок заставки (кроме дефолтного) и его файлы.</summary>
|
|
||||||
public sealed record RemoveBumperTemplateCommand(Guid ChannelId, Guid TemplateId)
|
|
||||||
: ICommand<Result>;
|
|
||||||
-35
@@ -1,35 +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 RemoveBumperTemplateCommandHandler(
|
|
||||||
IAppDbContext dbContext,
|
|
||||||
IBumperTemplateStorage storage
|
|
||||||
) : ICommandHandler<RemoveBumperTemplateCommand, Result>
|
|
||||||
{
|
|
||||||
public async Task<Result> Handle(
|
|
||||||
RemoveBumperTemplateCommand 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(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
|
||||||
if (template.IsDefault)
|
|
||||||
return Result.Failure(ChannelErrors.CannotRemoveDefaultBumperTemplate);
|
|
||||||
|
|
||||||
channel.RemoveBumperTemplate(command.TemplateId);
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
await storage.DeleteTemplateAsync(command.TemplateId, cancellationToken);
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>Удалить подблок (кроме последнего) из блока заставки.</summary>
|
|
||||||
public sealed record RemoveBumperTextVariantCommand(Guid ChannelId, Guid TemplateId, Guid VariantId)
|
|
||||||
: ICommand<Result>;
|
|
||||||
-34
@@ -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 RemoveBumperTextVariantCommandHandler(IAppDbContext dbContext)
|
|
||||||
: ICommandHandler<RemoveBumperTextVariantCommand, Result>
|
|
||||||
{
|
|
||||||
public async Task<Result> Handle(
|
|
||||||
RemoveBumperTextVariantCommand 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(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
|
||||||
if (template.FindVariant(command.VariantId) is null)
|
|
||||||
return Result.Failure(ChannelErrors.BumperTextVariantNotFound);
|
|
||||||
|
|
||||||
return template.RemoveVariant(command.VariantId)
|
|
||||||
? Result.Success()
|
|
||||||
: Result.Failure(ChannelErrors.CannotRemoveLastBumperTextVariant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
public sealed class RemoveBumperVariantCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<RemoveBumperVariantCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(
|
||||||
|
RemoveBumperVariantCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
|
dbContext,
|
||||||
|
command.TemplateId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (template is null)
|
||||||
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
|
if (template.FindVariant(command.VariantId) is null)
|
||||||
|
return Result.Failure(BumperErrors.VariantNotFound);
|
||||||
|
|
||||||
|
// Врезка могла выбрать этот подблок жёстко — тогда удаление оставило бы её без текста.
|
||||||
|
var used = await dbContext.JunctionElements.AnyAsync(
|
||||||
|
e => e.BumperVariantId == command.VariantId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (used)
|
||||||
|
return Result.Failure(BumperErrors.VariantInUse);
|
||||||
|
|
||||||
|
return template.RemoveVariant(command.VariantId)
|
||||||
|
? Result.Success()
|
||||||
|
: Result.Failure(BumperErrors.CannotRemoveLastVariant);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Синхронно рендерит примеры всех подблоков блока (с примерными названиями шоу). Каждый подблок —
|
|
||||||
/// в свой ассет-превью (id детерминирован по подблоку). БД не меняет, но пишет артефакты на диск —
|
|
||||||
/// поэтому это команда (действие с побочным эффектом), а не запрос.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record RenderBumperPreviewCommand(Guid ChannelId, Guid TemplateId) : ICommand<Result>;
|
|
||||||
+252
-126
@@ -1,126 +1,252 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Broadcast.Scheduling;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Application.Streaming;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
using TeleWave.Domain.Media;
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
public sealed class RenderBumperPreviewCommandHandler(
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
IAppDbContext dbContext,
|
|
||||||
IBumperRenderer renderer,
|
/// <summary>
|
||||||
IBumperTemplateStorage storage,
|
/// Рендерит пример каждого подблока. Блок общий, поэтому образцы подстановки берутся глазами
|
||||||
IImageStore imageStore,
|
/// выбранного канала: без него <c>{channel}</c> не на что заменить, а названия шоу были бы
|
||||||
IOptions<BumperOptions> bumperOptions,
|
/// случайными из библиотеки.
|
||||||
IOptions<StreamingOptions> streamingOptions
|
/// </summary>
|
||||||
) : ICommandHandler<RenderBumperPreviewCommand, Result>
|
public sealed class RenderBumperPreviewCommandHandler(
|
||||||
{
|
IAppDbContext dbContext,
|
||||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
IBumperRenderer renderer,
|
||||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
IBumperTemplateStorage storage,
|
||||||
|
IImageStore imageStore,
|
||||||
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
|
IMediaStorage mediaStorage,
|
||||||
private const int DefaultBumperDurationSeconds = 8;
|
IOptions<BumperOptions> bumperOptions,
|
||||||
|
IOptions<StreamingOptions> streamingOptions
|
||||||
public async Task<Result> Handle(
|
) : ICommandHandler<RenderBumperPreviewCommand, Result>
|
||||||
RenderBumperPreviewCommand query,
|
{
|
||||||
CancellationToken cancellationToken
|
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||||
)
|
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||||
{
|
|
||||||
var channel = await dbContext
|
public async Task<Result> Handle(
|
||||||
.Channels.AsNoTracking()
|
RenderBumperPreviewCommand command,
|
||||||
.Include(c => c.BumperTemplates)
|
CancellationToken cancellationToken
|
||||||
.ThenInclude(t => t.Variants)
|
)
|
||||||
.AsSplitQuery()
|
{
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
var template = await dbContext
|
||||||
if (channel is null)
|
.BumperTemplates.AsNoTracking()
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
.Include(t => t.Variants)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == command.TemplateId, cancellationToken);
|
||||||
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
|
if (template is null)
|
||||||
if (template is null)
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
|
||||||
|
var channel = command.ChannelId is { } channelId
|
||||||
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
? await dbContext
|
||||||
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
|
.Channels.AsNoTracking()
|
||||||
var seconds = template.AudioDurationSeconds is { } d and > 0
|
.FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken)
|
||||||
? d
|
: null;
|
||||||
: DefaultBumperDurationSeconds;
|
|
||||||
var aligned = (int)(
|
var samples = await SampleShowsAsync(channel, cancellationToken);
|
||||||
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
|
var context = BuildContext(channel, samples);
|
||||||
);
|
var backgroundPath = await ResolveImagePathAsync(
|
||||||
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
template.BackgroundImageId,
|
||||||
|
cancellationToken
|
||||||
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
|
);
|
||||||
var inputs = new BumperSpecInputs(
|
var posterPath = await ResolveImagePathAsync(samples.NextPosterId, cancellationToken);
|
||||||
fromName,
|
var aligned = BumperDuration.Aligned(
|
||||||
toName,
|
BumperDuration.TemplateSeconds(template),
|
||||||
audioPath,
|
_segmentSeconds
|
||||||
PosterAbsolutePath: null,
|
);
|
||||||
backgroundPath
|
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
||||||
);
|
|
||||||
|
var clips = await ResolveClipsAsync(template, cancellationToken);
|
||||||
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
|
||||||
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
||||||
{
|
{
|
||||||
var spec = BumperSpecFactory.Build(
|
var lines = variant
|
||||||
_bumper,
|
.Lines.OrderBy(l => l.Position)
|
||||||
channel.BumperFont,
|
.Select(l => new BumperRenderLine(
|
||||||
template,
|
l.Style,
|
||||||
variant,
|
l.Color,
|
||||||
aligned,
|
BumperPlaceholders.Resolve(l.Text, context)
|
||||||
inputs
|
))
|
||||||
);
|
.Where(l => !string.IsNullOrWhiteSpace(l.Text))
|
||||||
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
.ToList();
|
||||||
}
|
|
||||||
|
// У ролика своя длина — в превью она тоже должна быть настоящей, иначе админ увидит
|
||||||
return Result.Success();
|
// не ту заставку, которая пойдёт в эфир.
|
||||||
}
|
var clip = variant.BackgroundClipShowId is { } clipShowId
|
||||||
|
? clips.GetValueOrDefault(clipShowId)
|
||||||
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
|
: null;
|
||||||
private async Task<string?> ResolveBackgroundPathAsync(
|
|
||||||
BumperTemplate template,
|
var spec = BumperSpecFactory.Build(
|
||||||
CancellationToken cancellationToken
|
_bumper,
|
||||||
)
|
template,
|
||||||
{
|
clip is null ? aligned : BumperDuration.Aligned(clip.Seconds, _segmentSeconds),
|
||||||
if (template.BackgroundImageId is not { } imageId)
|
new BumperSpecInputs(
|
||||||
return null;
|
lines,
|
||||||
|
audioPath,
|
||||||
var extension = await dbContext
|
variant.Background == BumperBackground.Template ? null : posterPath,
|
||||||
.Images.AsNoTracking()
|
backgroundPath,
|
||||||
.Where(i => i.Id == imageId)
|
clip?.Path
|
||||||
.Select(i => i.FileExtension)
|
)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
);
|
||||||
|
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||||
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
|
}
|
||||||
}
|
|
||||||
|
return Result.Success();
|
||||||
/// <summary>
|
}
|
||||||
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
|
||||||
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
/// <summary>Ролик-подложка: плейлист и длительность.</summary>
|
||||||
/// </summary>
|
private sealed record ClipSource(string Path, int Seconds);
|
||||||
private async Task<(string From, string To)> SampleNamesAsync(
|
|
||||||
Channel channel,
|
/// <summary>Ролики-подложки подблоков блока, по шоу. Неготовые молча выпадают — превью не эфир.</summary>
|
||||||
CancellationToken cancellationToken
|
private async Task<Dictionary<Guid, ClipSource>> ResolveClipsAsync(
|
||||||
)
|
BumperTemplate template,
|
||||||
{
|
CancellationToken cancellationToken
|
||||||
var names = await (
|
)
|
||||||
from slot in dbContext.Slots.AsNoTracking()
|
{
|
||||||
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
|
var showIds = template
|
||||||
join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId
|
.Variants.Where(v => v.Background == BumperBackground.Clip)
|
||||||
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
|
.Select(v => v.BackgroundClipShowId)
|
||||||
where
|
.Where(id => id is not null)
|
||||||
layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show
|
.Select(id => id!.Value)
|
||||||
select show.Name
|
.Distinct()
|
||||||
)
|
.ToList();
|
||||||
.Distinct()
|
if (showIds.Count == 0)
|
||||||
.Take(2)
|
return [];
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
var episodes = await dbContext
|
||||||
return (
|
.Shows.AsNoTracking()
|
||||||
names.ElementAtOrDefault(0) ?? "Первое шоу",
|
.SelectMany(s => s.Episodes)
|
||||||
names.ElementAtOrDefault(1) ?? "Второе шоу"
|
.Where(e => showIds.Contains(e.ShowId))
|
||||||
);
|
.OrderBy(e => e.Position)
|
||||||
}
|
.Select(e => new { e.ShowId, e.MediaAssetId })
|
||||||
}
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
|
||||||
|
var ready = await dbContext
|
||||||
|
.MediaAssets.AsNoTracking()
|
||||||
|
.Where(a =>
|
||||||
|
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
|
||||||
|
)
|
||||||
|
.Select(a => new { a.Id, a.Duration })
|
||||||
|
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
||||||
|
|
||||||
|
var result = new Dictionary<Guid, ClipSource>();
|
||||||
|
foreach (var episode in episodes)
|
||||||
|
{
|
||||||
|
if (result.ContainsKey(episode.ShowId))
|
||||||
|
continue;
|
||||||
|
if (!ready.TryGetValue(episode.MediaAssetId, out var duration))
|
||||||
|
continue;
|
||||||
|
if (mediaStorage.ResolvePlaylistPath(episode.MediaAssetId) is not { } path)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
result[episode.ShowId] = new ClipSource(path, (int)Math.Ceiling(duration.TotalSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BumperContext BuildContext(Channel? channel, SampleShows samples)
|
||||||
|
{
|
||||||
|
var offset = TimeSpan.FromMinutes(
|
||||||
|
channel?.UtcOffsetMinutes ?? Channel.DefaultUtcOffsetMinutes
|
||||||
|
);
|
||||||
|
var moment = DateTimeOffset.UtcNow.ToOffset(offset);
|
||||||
|
|
||||||
|
return new BumperContext(
|
||||||
|
channel?.Name ?? "Канал",
|
||||||
|
channel?.Number,
|
||||||
|
moment,
|
||||||
|
samples.NowTitle,
|
||||||
|
samples.NextTitle,
|
||||||
|
"с1э5",
|
||||||
|
"с2э3",
|
||||||
|
samples.NextYear,
|
||||||
|
samples.NextGenre,
|
||||||
|
TimeOnly.FromDateTime(moment.AddMinutes(30).DateTime),
|
||||||
|
samples.SlotTitle,
|
||||||
|
// Анонсы в превью показываются образцом: настоящий вечер известен только ленте.
|
||||||
|
new BumperAnnouncement(samples.NextTitle, new TimeOnly(20, 0)),
|
||||||
|
new BumperAnnouncement(samples.NowTitle, new TimeOnly(20, 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пара шоу для образца. Берём те, что реально ходят в этом канале (через группы его слотов), —
|
||||||
|
/// иначе предпросмотр показывает библиотеку, а не канал.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<SampleShows> SampleShowsAsync(
|
||||||
|
Channel? channel,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var query = dbContext.Shows.AsNoTracking().AsQueryable();
|
||||||
|
if (channel?.TemplateId is { } templateId)
|
||||||
|
query =
|
||||||
|
from show in query
|
||||||
|
join item in dbContext.GroupItems.AsNoTracking() on show.Id equals item.ElementId
|
||||||
|
join slot in dbContext.Slots.AsNoTracking() on item.GroupId equals slot.GroupId
|
||||||
|
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
|
||||||
|
where layer.TemplateId == templateId && item.ElementKind == GroupElementKind.Show
|
||||||
|
select show;
|
||||||
|
|
||||||
|
var shows = await query
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Name,
|
||||||
|
s.Year,
|
||||||
|
s.PosterImageId,
|
||||||
|
})
|
||||||
|
.Distinct()
|
||||||
|
.Take(2)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var slotTitle = channel?.TemplateId is { } id
|
||||||
|
? await (
|
||||||
|
from slot in dbContext.Slots.AsNoTracking()
|
||||||
|
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
|
||||||
|
where layer.TemplateId == id
|
||||||
|
select slot.Title
|
||||||
|
).FirstOrDefaultAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var next = shows.ElementAtOrDefault(1) ?? shows.ElementAtOrDefault(0);
|
||||||
|
return new SampleShows(
|
||||||
|
shows.ElementAtOrDefault(0)?.Name ?? "Первое шоу",
|
||||||
|
next?.Name ?? "Второе шоу",
|
||||||
|
next?.Year,
|
||||||
|
null,
|
||||||
|
next?.PosterImageId,
|
||||||
|
slotTitle ?? "Вечернее кино"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ResolveImagePathAsync(
|
||||||
|
Guid? imageId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (imageId is not { } id)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var extension = await dbContext
|
||||||
|
.Images.AsNoTracking()
|
||||||
|
.Where(i => i.Id == id)
|
||||||
|
.Select(i => i.FileExtension)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
return extension is null ? null : imageStore.ResolvePath(id, extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SampleShows(
|
||||||
|
string NowTitle,
|
||||||
|
string NextTitle,
|
||||||
|
int? NextYear,
|
||||||
|
string? NextGenre,
|
||||||
|
Guid? NextPosterId,
|
||||||
|
string SlotTitle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>Отметить загруженный звук блока: расширение (с точкой) и длину в секундах (замер ffprobe).</summary>
|
|
||||||
public sealed record SetBumperTemplateAudioCommand(
|
|
||||||
Guid ChannelId,
|
|
||||||
Guid TemplateId,
|
|
||||||
string Extension,
|
|
||||||
double DurationSeconds
|
|
||||||
) : ICommand<Result>;
|
|
||||||
+6
-9
@@ -1,5 +1,4 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
@@ -13,15 +12,13 @@ public sealed class SetBumperTemplateAudioCommandHandler(IAppDbContext dbContext
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
dbContext,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
command.TemplateId,
|
||||||
if (channel is null)
|
cancellationToken
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
);
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
template.SetAudio(command.Extension, command.DurationSeconds);
|
template.SetAudio(command.Extension, command.DurationSeconds);
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
|
|||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>Привязать фон-картинку блока по ссылке на изображение из реестра (галерея).</summary>
|
|
||||||
public sealed record SetBumperTemplateBackgroundCommand(
|
|
||||||
Guid ChannelId,
|
|
||||||
Guid TemplateId,
|
|
||||||
Guid ImageId
|
|
||||||
) : ICommand<Result>;
|
|
||||||
+10
-8
@@ -2,6 +2,7 @@ using LiteCqrs;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Images;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
@@ -13,15 +14,16 @@ public sealed class SetBumperTemplateBackgroundCommandHandler(IAppDbContext dbCo
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
dbContext,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
command.TemplateId,
|
||||||
if (channel is null)
|
cancellationToken
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
);
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
|
if (!await dbContext.Images.AnyAsync(i => i.Id == command.ImageId, cancellationToken))
|
||||||
|
return Result.Failure(ImageErrors.NotFound);
|
||||||
|
|
||||||
template.SetBackgroundImage(command.ImageId);
|
template.SetBackgroundImage(command.ImageId);
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>Обновить оформление блока заставки: имя и цвета (в нотации ffmpeg).</summary>
|
|
||||||
public sealed record UpdateBumperTemplateCommand(
|
|
||||||
Guid ChannelId,
|
|
||||||
Guid TemplateId,
|
|
||||||
string Name,
|
|
||||||
string BackgroundColor,
|
|
||||||
string BackgroundColor2,
|
|
||||||
string AccentColor,
|
|
||||||
string TextColor
|
|
||||||
) : ICommand<Result>;
|
|
||||||
+8
-17
@@ -1,5 +1,4 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
@@ -13,23 +12,15 @@ public sealed class UpdateBumperTemplateCommandHandler(IAppDbContext dbContext)
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
dbContext,
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken);
|
command.TemplateId,
|
||||||
if (channel is null)
|
cancellationToken
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
|
||||||
|
|
||||||
template.UpdateStyle(
|
|
||||||
command.Name.Trim(),
|
|
||||||
command.BackgroundColor,
|
|
||||||
command.BackgroundColor2,
|
|
||||||
command.AccentColor,
|
|
||||||
command.TextColor
|
|
||||||
);
|
);
|
||||||
|
if (template is null)
|
||||||
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
|
template.UpdateStyle(command.Style);
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
public sealed partial class UpdateBumperTemplateCommandValidator
|
|
||||||
: AbstractValidator<UpdateBumperTemplateCommand>
|
|
||||||
{
|
|
||||||
public UpdateBumperTemplateCommandValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
|
||||||
|
|
||||||
// Цвета уходят в строку ffmpeg-фильтра без экранирования — допускаем только безопасный формат
|
|
||||||
// (0xRRGGBB[AA], #RRGGBB[AA] или имя цвета), чтобы исключить инъекцию синтаксиса фильтра.
|
|
||||||
RuleFor(x => x.BackgroundColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
|
||||||
RuleFor(x => x.BackgroundColor2).Must(BeSafeColor).WithMessage(ColorMessage);
|
|
||||||
RuleFor(x => x.AccentColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
|
||||||
RuleFor(x => x.TextColor).Must(BeSafeColor).WithMessage(ColorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private const string ColorMessage =
|
|
||||||
"Цвет должен быть в формате 0xRRGGBB, #RRGGBB или именем (например white).";
|
|
||||||
|
|
||||||
private static bool BeSafeColor(string? value) =>
|
|
||||||
!string.IsNullOrWhiteSpace(value) && ColorRegex().IsMatch(value);
|
|
||||||
|
|
||||||
[GeneratedRegex(
|
|
||||||
@"^((0x|#)?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?|[A-Za-z]{2,20}(@[0-9]?\.?[0-9]+)?)$"
|
|
||||||
)]
|
|
||||||
private static partial Regex ColorRegex();
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
using TeleWave.Domain.Broadcast;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
/// <summary>Обновить подблок: имя, режим текста, текст, правило показа и вес.</summary>
|
|
||||||
public sealed record UpdateBumperTextVariantCommand(
|
|
||||||
Guid ChannelId,
|
|
||||||
Guid TemplateId,
|
|
||||||
Guid VariantId,
|
|
||||||
string Name,
|
|
||||||
BumperTextKind Kind,
|
|
||||||
string NowLabel,
|
|
||||||
string NextLabel,
|
|
||||||
string Line1,
|
|
||||||
string Line2,
|
|
||||||
BumperTrigger Trigger,
|
|
||||||
int Weight
|
|
||||||
) : ICommand<Result>;
|
|
||||||
-47
@@ -1,47 +0,0 @@
|
|||||||
using LiteCqrs;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TeleWave.Application.Common.Interfaces;
|
|
||||||
using TeleWave.Application.Common.Models;
|
|
||||||
using TeleWave.Domain.Broadcast;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContext)
|
|
||||||
: ICommandHandler<UpdateBumperTextVariantCommand, Result>
|
|
||||||
{
|
|
||||||
public async Task<Result> Handle(
|
|
||||||
UpdateBumperTextVariantCommand 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(ChannelErrors.NotFound);
|
|
||||||
|
|
||||||
var template = channel.FindBumperTemplate(command.TemplateId);
|
|
||||||
if (template is null)
|
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
|
||||||
|
|
||||||
var variant = template.FindVariant(command.VariantId);
|
|
||||||
if (variant is null)
|
|
||||||
return Result.Failure(ChannelErrors.BumperTextVariantNotFound);
|
|
||||||
|
|
||||||
variant.Update(
|
|
||||||
command.Name.Trim(),
|
|
||||||
new BumperTextContent(
|
|
||||||
command.Kind,
|
|
||||||
command.NowLabel,
|
|
||||||
command.NextLabel,
|
|
||||||
command.Line1,
|
|
||||||
command.Line2
|
|
||||||
),
|
|
||||||
command.Trigger,
|
|
||||||
command.Weight
|
|
||||||
);
|
|
||||||
return Result.Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
|
||||||
|
|
||||||
public sealed class UpdateBumperTextVariantCommandValidator
|
|
||||||
: AbstractValidator<UpdateBumperTextVariantCommand>
|
|
||||||
{
|
|
||||||
public UpdateBumperTextVariantCommandValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(64);
|
|
||||||
RuleFor(x => x.NowLabel).MaximumLength(64);
|
|
||||||
RuleFor(x => x.NextLabel).MaximumLength(64);
|
|
||||||
RuleFor(x => x.Line1).MaximumLength(120);
|
|
||||||
RuleFor(x => x.Line2).MaximumLength(120);
|
|
||||||
RuleFor(x => x.Weight).InclusiveBetween(0, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
|
public sealed class UpdateBumperVariantCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<UpdateBumperVariantCommand, Result>
|
||||||
|
{
|
||||||
|
public async Task<Result> Handle(
|
||||||
|
UpdateBumperVariantCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var template = await BumperTemplateLoader.LoadAsync(
|
||||||
|
dbContext,
|
||||||
|
command.TemplateId,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
if (template is null)
|
||||||
|
return Result.Failure(BumperErrors.TemplateNotFound);
|
||||||
|
|
||||||
|
var variant = template.FindVariant(command.VariantId);
|
||||||
|
if (variant is null)
|
||||||
|
return Result.Failure(BumperErrors.VariantNotFound);
|
||||||
|
|
||||||
|
// Незнакомый плейсхолдер ловим здесь: в эфире он превратился бы в пустоту, и заметить это
|
||||||
|
// было бы уже некому.
|
||||||
|
var unknown = command
|
||||||
|
.Input.Lines.SelectMany(l => BumperPlaceholders.UnknownTokens(l.Text))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
if (unknown.Count > 0)
|
||||||
|
return Result.Failure(BumperErrors.UnknownPlaceholders(unknown));
|
||||||
|
|
||||||
|
variant.Update(
|
||||||
|
command.Input.Name,
|
||||||
|
command.Input.Trigger,
|
||||||
|
command.Input.Background,
|
||||||
|
command.Input.Weight,
|
||||||
|
command.Input.BackgroundClipShowId
|
||||||
|
);
|
||||||
|
variant.SetLines(
|
||||||
|
command.Input.Lines.Select(
|
||||||
|
(line, index) => BumperLine.Create(index, line.Style, line.Color, line.Text)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +1,30 @@
|
|||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast;
|
namespace TeleWave.Application.Broadcast;
|
||||||
|
|
||||||
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
|
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
|
||||||
|
|
||||||
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
|
public sealed record ChannelDto(
|
||||||
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
|
Guid Id,
|
||||||
|
string Name,
|
||||||
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
|
string Slug,
|
||||||
public sealed record BumperTextVariantDto(
|
bool IsEnabled,
|
||||||
Guid Id,
|
int? Number,
|
||||||
int Position,
|
int UtcOffsetMinutes,
|
||||||
string Name,
|
TimeOnly DayStartTime,
|
||||||
BumperTextKind Kind,
|
Guid? TemplateId,
|
||||||
string NowLabel,
|
Guid? FillerAssetId,
|
||||||
string NextLabel,
|
/// <summary>Иконка канала для внешних IPTV-плееров (M3U/XMLTV); null — отдаётся заглушка.</summary>
|
||||||
string Line1,
|
Guid? IconImageId,
|
||||||
string Line2,
|
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
||||||
BumperTrigger Trigger,
|
ViewerSettingsDto Viewer
|
||||||
int Weight
|
);
|
||||||
);
|
|
||||||
|
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
|
||||||
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
|
public sealed record ViewerSettingsDto(
|
||||||
public sealed record BumperTemplateDto(
|
Guid? LogoImageId,
|
||||||
Guid Id,
|
LogoCorner LogoCorner,
|
||||||
int Position,
|
double LogoOpacity,
|
||||||
bool IsDefault,
|
bool ShowClock,
|
||||||
string Name,
|
double AnalogFilterStrength
|
||||||
string BackgroundColor,
|
);
|
||||||
string BackgroundColor2,
|
|
||||||
string AccentColor,
|
|
||||||
string TextColor,
|
|
||||||
Guid? BackgroundImageId,
|
|
||||||
bool HasAudio,
|
|
||||||
double? AudioDurationSeconds,
|
|
||||||
IReadOnlyList<BumperTextVariantDto> Variants
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record ChannelDto(
|
|
||||||
Guid Id,
|
|
||||||
string Name,
|
|
||||||
string Slug,
|
|
||||||
bool IsEnabled,
|
|
||||||
int? Number,
|
|
||||||
int UtcOffsetMinutes,
|
|
||||||
TimeOnly DayStartTime,
|
|
||||||
Guid? TemplateId,
|
|
||||||
bool BumpersEnabled,
|
|
||||||
BumperSettingsDto Bumper,
|
|
||||||
IReadOnlyList<BumperTemplateDto> BumperTemplates,
|
|
||||||
Guid? FillerAssetId,
|
|
||||||
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
|
||||||
ViewerSettingsDto Viewer
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
|
|
||||||
public sealed record ViewerSettingsDto(
|
|
||||||
Guid? LogoImageId,
|
|
||||||
LogoCorner LogoCorner,
|
|
||||||
double LogoOpacity,
|
|
||||||
bool ShowClock,
|
|
||||||
double AnalogFilterStrength
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast;
|
namespace TeleWave.Application.Broadcast;
|
||||||
|
|
||||||
public static class ChannelErrors
|
public static class ChannelErrors
|
||||||
{
|
{
|
||||||
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
|
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
|
||||||
|
|
||||||
public static readonly Error NumberTaken = Error.Conflict(
|
public static readonly Error NumberTaken = Error.Conflict(
|
||||||
"Channels.NumberTaken",
|
"Channels.NumberTaken",
|
||||||
"Канал с таким номером уже есть."
|
"Канал с таким номером уже есть."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error TemplateNotFound = Error.NotFound(
|
public static readonly Error TemplateNotFound = Error.NotFound(
|
||||||
"Channels.TemplateNotFound",
|
"Channels.TemplateNotFound",
|
||||||
"У канала нет шаблона сетки."
|
"У канала нет шаблона сетки."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error DuplicateSlug = Error.Conflict(
|
public static readonly Error DuplicateSlug = Error.Conflict(
|
||||||
"Channels.DuplicateSlug",
|
"Channels.DuplicateSlug",
|
||||||
"Канал с таким slug уже существует."
|
"Канал с таким slug уже существует."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error BumperTemplateNotFound = Error.NotFound(
|
public static readonly Error BumperTemplateNotFound = Error.NotFound(
|
||||||
"Channels.BumperTemplateNotFound",
|
"Channels.BumperTemplateNotFound",
|
||||||
"Блок заставки не найден."
|
"Блок заставки не найден."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
|
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
|
||||||
"Channels.CannotRemoveDefaultBumperTemplate",
|
"Channels.CannotRemoveDefaultBumperTemplate",
|
||||||
"Дефолтный блок заставки удалить нельзя."
|
"Дефолтный блок заставки удалить нельзя."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error BumperTextVariantNotFound = Error.NotFound(
|
public static readonly Error BumperTextVariantNotFound = Error.NotFound(
|
||||||
"Channels.BumperTextVariantNotFound",
|
"Channels.BumperTextVariantNotFound",
|
||||||
"Подблок заставки не найден."
|
"Подблок заставки не найден."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
|
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
|
||||||
"Channels.CannotRemoveLastBumperTextVariant",
|
"Channels.CannotRemoveLastBumperTextVariant",
|
||||||
"Нельзя удалить последний подблок — нужен хотя бы один."
|
"Нельзя удалить последний подблок — нужен хотя бы один."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error AssetNotFound = Error.NotFound(
|
public static readonly Error AssetNotFound = Error.NotFound(
|
||||||
"Channels.AssetNotFound",
|
"Channels.AssetNotFound",
|
||||||
"Медиа-ассет не найден."
|
"Медиа-ассет не найден."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error InvalidBumperFile = Error.Validation(
|
public static readonly Error InvalidBumperFile = Error.Validation(
|
||||||
"Channels.InvalidBumperFile",
|
"Channels.InvalidBumperFile",
|
||||||
"Недопустимый файл заставки (формат или размер)."
|
"Недопустимый файл заставки (формат или размер)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,46 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.GetChannel;
|
namespace TeleWave.Application.Broadcast.GetChannel;
|
||||||
|
|
||||||
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||||
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
|
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<ChannelDto>> Handle(
|
public async Task<Result<ChannelDto>> Handle(
|
||||||
GetChannelQuery query,
|
GetChannelQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
|
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
|
||||||
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
|
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
|
||||||
var channel = await dbContext
|
var channel = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.Include(c => c.BumperTemplates)
|
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
||||||
.ThenInclude(t => t.Variants)
|
if (channel is null)
|
||||||
.AsSplitQuery()
|
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
|
||||||
if (channel is null)
|
return Result.Success(
|
||||||
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
new ChannelDto(
|
||||||
|
channel.Id,
|
||||||
var bumperTemplates = channel
|
channel.Name,
|
||||||
.BumperTemplates.OrderBy(t => t.Position)
|
channel.Slug,
|
||||||
.Select(t => new BumperTemplateDto(
|
channel.IsEnabled,
|
||||||
t.Id,
|
channel.Number,
|
||||||
t.Position,
|
channel.UtcOffsetMinutes,
|
||||||
t.IsDefault,
|
channel.DayStartTime,
|
||||||
t.Name,
|
channel.TemplateId,
|
||||||
t.BackgroundColor,
|
channel.FillerAssetId,
|
||||||
t.BackgroundColor2,
|
channel.IconImageId,
|
||||||
t.AccentColor,
|
new ViewerSettingsDto(
|
||||||
t.TextColor,
|
channel.LogoImageId,
|
||||||
t.BackgroundImageId,
|
channel.LogoCorner,
|
||||||
t.AudioExtension is not null,
|
channel.LogoOpacity,
|
||||||
t.AudioDurationSeconds,
|
channel.ShowClock,
|
||||||
t.Variants.OrderBy(v => v.Position)
|
channel.AnalogFilterStrength
|
||||||
.Select(v => new BumperTextVariantDto(
|
)
|
||||||
v.Id,
|
)
|
||||||
v.Position,
|
);
|
||||||
v.Name,
|
}
|
||||||
v.Kind,
|
}
|
||||||
v.NowLabel,
|
|
||||||
v.NextLabel,
|
|
||||||
v.Line1,
|
|
||||||
v.Line2,
|
|
||||||
v.Trigger,
|
|
||||||
v.Weight
|
|
||||||
))
|
|
||||||
.ToList()
|
|
||||||
))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Result.Success(
|
|
||||||
new ChannelDto(
|
|
||||||
channel.Id,
|
|
||||||
channel.Name,
|
|
||||||
channel.Slug,
|
|
||||||
channel.IsEnabled,
|
|
||||||
channel.Number,
|
|
||||||
channel.UtcOffsetMinutes,
|
|
||||||
channel.DayStartTime,
|
|
||||||
channel.TemplateId,
|
|
||||||
channel.BumpersEnabled,
|
|
||||||
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
|
|
||||||
bumperTemplates,
|
|
||||||
channel.FillerAssetId,
|
|
||||||
new ViewerSettingsDto(
|
|
||||||
channel.LogoImageId,
|
|
||||||
channel.LogoCorner,
|
|
||||||
channel.LogoOpacity,
|
|
||||||
channel.ShowClock,
|
|
||||||
channel.AnalogFilterStrength
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+91
-42
@@ -1,5 +1,6 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
@@ -43,22 +44,31 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
|
|||||||
.Select(s => new { s.Id, s.Name })
|
.Select(s => new { s.Id, s.Name })
|
||||||
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
|
||||||
|
|
||||||
// Подблоки заставок в окне — чтобы показать в расписании, какая именно заставка и с каким текстом.
|
// Заставки в окне: берём их из кэша по ассету — там лежит ровно тот текст, который играл,
|
||||||
var variantIds = entries
|
// с уже подставленными плейсхолдерами. Собирать его заново из подблока значило бы гадать.
|
||||||
.Where(e =>
|
var bumperAssetIds = entries
|
||||||
e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper && e.BumperVariantId != null
|
.Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper)
|
||||||
)
|
.Select(e => e.MediaAssetId)
|
||||||
.Select(e => e.BumperVariantId!.Value)
|
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
var variants =
|
var bumpers =
|
||||||
variantIds.Count == 0
|
bumperAssetIds.Count == 0
|
||||||
? []
|
? []
|
||||||
: await dbContext
|
: await (
|
||||||
.BumperTextVariants.AsNoTracking()
|
from cache in dbContext.BumperAssets.AsNoTracking()
|
||||||
.Where(v => variantIds.Contains(v.Id))
|
join variant in dbContext.BumperTextVariants.AsNoTracking()
|
||||||
.ToListAsync(cancellationToken);
|
on cache.VariantId equals variant.Id
|
||||||
var variantsById = variants.ToDictionary(v => v.Id);
|
where bumperAssetIds.Contains(cache.MediaAssetId)
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
cache.MediaAssetId,
|
||||||
|
variant.Name,
|
||||||
|
cache.RenderedLinesJson,
|
||||||
|
}
|
||||||
|
).ToListAsync(cancellationToken);
|
||||||
|
var bumpersByAsset = bumpers
|
||||||
|
.GroupBy(b => b.MediaAssetId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
// Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки.
|
// Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки.
|
||||||
var assetIds = entries
|
var assetIds = entries
|
||||||
@@ -72,23 +82,45 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
|
|||||||
.Select(a => new { a.Id, a.OriginalFileName })
|
.Select(a => new { a.Id, a.OriginalFileName })
|
||||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||||
|
|
||||||
// «Из какого шоу» для заставки берём из ближайшей предыдущей программы в упорядоченном окне.
|
// Франшизы записей: у фильма серия всегда одна, и «Серия 1» в расписании — пустая метка.
|
||||||
Guid? prevProgramShowId = null;
|
// Осмысленно другое — какой частью коллекции он шёл.
|
||||||
|
var collectionIds = entries
|
||||||
|
.Where(e => e.CollectionId != null)
|
||||||
|
.Select(e => e.CollectionId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var collectionParts =
|
||||||
|
collectionIds.Count == 0
|
||||||
|
? []
|
||||||
|
: await (
|
||||||
|
from item in dbContext.CollectionItems.AsNoTracking()
|
||||||
|
join collection in dbContext.Collections.AsNoTracking()
|
||||||
|
on item.CollectionId equals collection.Id
|
||||||
|
where collectionIds.Contains(item.CollectionId)
|
||||||
|
select new CollectionPart(
|
||||||
|
item.CollectionId,
|
||||||
|
item.ShowId,
|
||||||
|
item.Position,
|
||||||
|
collection.Name
|
||||||
|
)
|
||||||
|
).ToListAsync(cancellationToken);
|
||||||
|
var partsByCollection = collectionParts
|
||||||
|
.GroupBy(p => p.CollectionId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderBy(p => p.Position).ToList());
|
||||||
|
|
||||||
var dtos = new List<ScheduleEntryDto>(entries.Count);
|
var dtos = new List<ScheduleEntryDto>(entries.Count);
|
||||||
foreach (var e in entries)
|
foreach (var e in entries)
|
||||||
{
|
{
|
||||||
string? bumperName = null;
|
string? bumperName = null;
|
||||||
string? bumperText = null;
|
string? bumperText = null;
|
||||||
if (
|
if (bumpersByAsset.TryGetValue(e.MediaAssetId, out var bumper))
|
||||||
e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper
|
|
||||||
&& e.BumperVariantId is { } vid
|
|
||||||
&& variantsById.TryGetValue(vid, out var variant)
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
bumperName = variant.Name;
|
bumperName = bumper.Name;
|
||||||
bumperText = BumperText(variant, prevProgramShowId, e.ShowId, showNames);
|
bumperText = BumperText(bumper.RenderedLinesJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var franchise = FranchisePart(partsByCollection, e.CollectionId, e.ShowId);
|
||||||
|
|
||||||
dtos.Add(
|
dtos.Add(
|
||||||
new ScheduleEntryDto(
|
new ScheduleEntryDto(
|
||||||
e.Id,
|
e.Id,
|
||||||
@@ -103,37 +135,54 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext)
|
|||||||
? EpisodeName.ParseLabel(name)
|
? EpisodeName.ParseLabel(name)
|
||||||
: null,
|
: null,
|
||||||
bumperName,
|
bumperName,
|
||||||
bumperText
|
bumperText,
|
||||||
|
franchise?.Part,
|
||||||
|
franchise?.Total,
|
||||||
|
franchise?.Name
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (e.Kind == Domain.Broadcast.ScheduleEntryKind.Program)
|
|
||||||
prevProgramShowId = e.ShowId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
|
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Место записи во франшизе: «часть N из M» и её название.</summary>
|
||||||
|
private sealed record FranchisePosition(int Part, int Total, string Name);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Текст заставки для метки в расписании: для «Сейчас/Далее» — подписи + названия шоу (из→в),
|
/// Какой частью коллекции шла запись. Номер ищется по позиции шоу в текущем составе, а не по
|
||||||
/// для свободного текста — заданные строки. Возвращает null, если показывать нечего.
|
/// индексу записи: франшизу могли переупорядочить, и «часть 2» обязана означать вторую сейчас.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string? BumperText(
|
private static FranchisePosition? FranchisePart(
|
||||||
Domain.Broadcast.BumperTextVariant variant,
|
IReadOnlyDictionary<Guid, List<CollectionPart>> partsByCollection,
|
||||||
Guid? fromShowId,
|
Guid? collectionId,
|
||||||
Guid? toShowId,
|
Guid? showId
|
||||||
IReadOnlyDictionary<Guid, string> showNames
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (variant.Kind == Domain.Broadcast.BumperTextKind.Free)
|
if (
|
||||||
{
|
collectionId is not { } id
|
||||||
var parts = new[] { variant.Line1, variant.Line2 }
|
|| showId is not { } show
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
|| !partsByCollection.TryGetValue(id, out var ordered)
|
||||||
.ToArray();
|
)
|
||||||
return parts.Length == 0 ? null : string.Join(" · ", parts);
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
string Name(Guid? id) => id is { } g ? showNames.GetValueOrDefault(g, "…") : "…";
|
var at = ordered.FindIndex(p => p.ShowId == show);
|
||||||
return $"{variant.NowLabel} {Name(fromShowId)} · {variant.NextLabel} {Name(toShowId)}";
|
return at < 0 ? null : new FranchisePosition(at + 1, ordered.Count, ordered[0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Позиция шоу в коллекции вместе с её названием — то, чем считается номер части.</summary>
|
||||||
|
private sealed record CollectionPart(Guid CollectionId, Guid ShowId, int Position, string Name);
|
||||||
|
|
||||||
|
/// <summary>Строки сыгравшей заставки одной меткой для расписания; null — показывать нечего.</summary>
|
||||||
|
private static string? BumperText(string? renderedLinesJson)
|
||||||
|
{
|
||||||
|
var text = string.Join(
|
||||||
|
" · ",
|
||||||
|
BumperRenderedText
|
||||||
|
.FromJson(renderedLinesJson)
|
||||||
|
.Select(l => l.Text)
|
||||||
|
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||||
|
);
|
||||||
|
return text.Length == 0 ? null : text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,12 @@ public sealed record ScheduleEntryDto(
|
|||||||
string? SeasonEpisode,
|
string? SeasonEpisode,
|
||||||
// Для заставок (Kind == Bumper): имя подблока и его текст — для метки в админ-расписании.
|
// Для заставок (Kind == Bumper): имя подблока и его текст — для метки в админ-расписании.
|
||||||
string? BumperName = null,
|
string? BumperName = null,
|
||||||
string? BumperText = null
|
string? BumperText = null,
|
||||||
|
/// <summary>
|
||||||
|
/// Какой частью франшизы шла запись и сколько их всего. У фильма серия всегда одна, и «Серия 1»
|
||||||
|
/// в расписании ничего не значит; «часть 3 из 3» — значит.
|
||||||
|
/// </summary>
|
||||||
|
int? CollectionPart = null,
|
||||||
|
int? CollectionParts = null,
|
||||||
|
string? CollectionName = null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
namespace TeleWave.Application.Broadcast.Scheduling;
|
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||||
|
|
||||||
public sealed class SchedulerOptions
|
public sealed class SchedulerOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "Scheduler";
|
public const string SectionName = "Scheduler";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
|
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
|
||||||
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
|
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int HorizonDays { get; init; } = 7;
|
public int HorizonDays { get; init; } = 7;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
|
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
|
||||||
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
|
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int RetentionDays { get; init; } = 90;
|
public int RetentionDays { get; init; } = 90;
|
||||||
|
|
||||||
/// <summary>Период тика фонового планировщика, минуты.</summary>
|
/// <summary>Период тика фонового планировщика, минуты.</summary>
|
||||||
public int TickMinutes { get; init; } = 30;
|
public int TickMinutes { get; init; } = 30;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-18
@@ -1,18 +1,12 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Broadcast;
|
|
||||||
|
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
|
||||||
|
public sealed record UpdateChannelSettingsCommand(
|
||||||
public sealed record UpdateChannelSettingsCommand(
|
Guid ChannelId,
|
||||||
Guid ChannelId,
|
string Name,
|
||||||
string Name,
|
bool IsEnabled,
|
||||||
bool IsEnabled,
|
Guid? FillerAssetId,
|
||||||
bool BumpersEnabled,
|
Guid? IconImageId
|
||||||
BumperSettingsInput Bumper,
|
) : ICommand<Result>;
|
||||||
Guid? FillerAssetId
|
|
||||||
) : ICommand<Result>;
|
|
||||||
|
|
||||||
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
|
|
||||||
/// показа сюда не входят — они задаются на элементе стыка.</summary>
|
|
||||||
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
|
|
||||||
|
|||||||
+41
-42
@@ -1,42 +1,41 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
|
|
||||||
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
||||||
: ICommandHandler<UpdateChannelSettingsCommand, Result>
|
: ICommandHandler<UpdateChannelSettingsCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
UpdateChannelSettingsCommand command,
|
UpdateChannelSettingsCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
||||||
c => c.Id == command.ChannelId,
|
c => c.Id == command.ChannelId,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
return Result.Failure(ChannelErrors.NotFound);
|
||||||
|
|
||||||
if (command.FillerAssetId is { } fillerId)
|
if (command.FillerAssetId is { } fillerId)
|
||||||
{
|
{
|
||||||
var exists = await dbContext.MediaAssets.AnyAsync(
|
var exists = await dbContext.MediaAssets.AnyAsync(
|
||||||
a => a.Id == fillerId,
|
a => a.Id == fillerId,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (!exists)
|
if (!exists)
|
||||||
return Result.Failure(ChannelErrors.AssetNotFound);
|
return Result.Failure(ChannelErrors.AssetNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.UpdateSettings(
|
channel.UpdateSettings(
|
||||||
command.Name,
|
command.Name,
|
||||||
command.IsEnabled,
|
command.IsEnabled,
|
||||||
command.BumpersEnabled,
|
command.FillerAssetId,
|
||||||
command.FillerAssetId
|
command.IconImageId
|
||||||
);
|
);
|
||||||
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
|
return Result.Success();
|
||||||
return Result.Success();
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
+12
-12
@@ -1,12 +1,12 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
|
|
||||||
public sealed class UpdateChannelSettingsCommandValidator
|
public sealed class UpdateChannelSettingsCommandValidator
|
||||||
: AbstractValidator<UpdateChannelSettingsCommand>
|
: AbstractValidator<UpdateChannelSettingsCommand>
|
||||||
{
|
{
|
||||||
public UpdateChannelSettingsCommandValidator()
|
public UpdateChannelSettingsCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace TeleWave.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Настройки отладочного дампа. Секция «Debug».</summary>
|
||||||
|
public sealed class DebugExportOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Debug";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Куда класть копии архивов. Пусто — не класть никуда, отдавать только в браузер: на сервере
|
||||||
|
/// дампы на диске не нужны, а в разработке путь указывает на папку рядом с репозиторием.
|
||||||
|
/// Относительный путь считается от рабочего каталога приложения.
|
||||||
|
/// </summary>
|
||||||
|
public string? ExportDirectory { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сколько последних архивов держать в каталоге. Дамп канала — это мегабайты, а собирают их
|
||||||
|
/// пачками, отлаживая одну и ту же сетку; без потолка папка растёт молча.
|
||||||
|
/// </summary>
|
||||||
|
public int KeepLast { get; init; } = 20;
|
||||||
|
}
|
||||||
@@ -1,48 +1,54 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using TeleWave.Domain.Auth;
|
using TeleWave.Domain.Auth;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Images;
|
using TeleWave.Domain.Images;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Notifications;
|
||||||
using TeleWave.Domain.Settings;
|
using TeleWave.Domain.Programming;
|
||||||
|
using TeleWave.Domain.Settings;
|
||||||
namespace TeleWave.Application.Common.Interfaces;
|
|
||||||
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
public interface IAppDbContext
|
|
||||||
{
|
public interface IAppDbContext
|
||||||
DbSet<RefreshToken> RefreshTokens { get; }
|
{
|
||||||
DbSet<MediaAsset> MediaAssets { get; }
|
DbSet<RefreshToken> RefreshTokens { get; }
|
||||||
DbSet<Show> Shows { get; }
|
DbSet<MediaAsset> MediaAssets { get; }
|
||||||
DbSet<Genre> Genres { get; }
|
DbSet<Show> Shows { get; }
|
||||||
DbSet<GenreAlias> GenreAliases { get; }
|
DbSet<Genre> Genres { get; }
|
||||||
DbSet<ShowGenre> ShowGenres { get; }
|
DbSet<GenreAlias> GenreAliases { get; }
|
||||||
DbSet<Collection> Collections { get; }
|
DbSet<ShowGenre> ShowGenres { get; }
|
||||||
DbSet<CollectionItem> CollectionItems { get; }
|
DbSet<Collection> Collections { get; }
|
||||||
DbSet<Group> Groups { get; }
|
DbSet<CollectionItem> CollectionItems { get; }
|
||||||
DbSet<GroupItem> GroupItems { get; }
|
DbSet<Group> Groups { get; }
|
||||||
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
|
DbSet<GroupItem> GroupItems { get; }
|
||||||
DbSet<GridLayer> GridLayers { get; }
|
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
|
||||||
DbSet<Slot> Slots { get; }
|
DbSet<GridLayer> GridLayers { get; }
|
||||||
DbSet<SlotState> SlotStates { get; }
|
DbSet<Slot> Slots { get; }
|
||||||
DbSet<JunctionTemplate> JunctionTemplates { get; }
|
DbSet<SlotState> SlotStates { get; }
|
||||||
DbSet<JunctionElement> JunctionElements { get; }
|
DbSet<JunctionTemplate> JunctionTemplates { get; }
|
||||||
DbSet<Channel> Channels { get; }
|
DbSet<JunctionElement> JunctionElements { get; }
|
||||||
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
DbSet<Channel> Channels { get; }
|
||||||
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
||||||
DbSet<BumperAsset> BumperAssets { get; }
|
DbSet<BumperTemplate> BumperTemplates { get; }
|
||||||
DbSet<AppSetting> AppSettings { get; }
|
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
||||||
DbSet<Image> Images { get; }
|
DbSet<TelegramSettings> TelegramSettings { get; }
|
||||||
|
DbSet<TelegramSubscriber> TelegramSubscribers { get; }
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
DbSet<TelegramSubscription> TelegramSubscriptions { get; }
|
||||||
|
DbSet<TelegramLinkCode> TelegramLinkCodes { get; }
|
||||||
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
|
DbSet<BumperAsset> BumperAssets { get; }
|
||||||
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
|
DbSet<AppSetting> AppSettings { get; }
|
||||||
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
|
DbSet<Image> Images { get; }
|
||||||
|
|
||||||
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
|
|
||||||
/// Вызывать внутри открытой транзакции.</summary>
|
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
|
||||||
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
|
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
|
||||||
}
|
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
|
||||||
|
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
|
||||||
|
/// Вызывать внутри открытой транзакции.</summary>
|
||||||
|
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Common.Interfaces;
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Готовая строка заставки: роль, цвет из палитры блока и уже подставленный текст.</summary>
|
||||||
|
public sealed record BumperRenderLine(BumperLineStyle Style, BumperLineColor Color, string Text);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Полная спецификация одной заставки для рендера: оформление канала + подписи + названия шоу.
|
/// Полная спецификация одной заставки для рендера: оформление блока + готовые строки. Плейсхолдеры
|
||||||
|
/// в <see cref="Lines"/> уже подставлены — рендер работает с текстом, а не с шаблоном.
|
||||||
/// <see cref="DurationSeconds"/> уже выровнена на длину сегмента (готовит оркестратор), а
|
/// <see cref="DurationSeconds"/> уже выровнена на длину сегмента (готовит оркестратор), а
|
||||||
/// <see cref="FontFile"/> — абсолютный путь к TTF внутри контейнера.
|
/// <see cref="FontFile"/> — абсолютный путь к TTF внутри контейнера.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -14,18 +20,21 @@ public sealed record BumperRenderSpec(
|
|||||||
string AccentColor,
|
string AccentColor,
|
||||||
string TextColor,
|
string TextColor,
|
||||||
string FontFile,
|
string FontFile,
|
||||||
string NowLabel,
|
IReadOnlyList<BumperRenderLine> Lines,
|
||||||
string NowTitle,
|
|
||||||
string NextLabel,
|
|
||||||
string NextTitle,
|
|
||||||
string? BackgroundFile = null,
|
string? BackgroundFile = null,
|
||||||
string? MusicFile = null,
|
string? MusicFile = null,
|
||||||
/// <summary>Постер шоу как фон (используется, если нет загруженного фона канала; затемняется).</summary>
|
/// <summary>Постер шоу как фон (используется, если подблок его запросил; затемняется).</summary>
|
||||||
string? PosterFile = null,
|
string? PosterFile = null,
|
||||||
/// <summary>Режим свободного текста: вместо «Сейчас/Далее» рисуются <see cref="FreeLine1"/>/<see cref="FreeLine2"/>.</summary>
|
/// <summary>
|
||||||
bool FreeText = false,
|
/// Готовый ролик как подложка: плейлист ассета из библиотеки. Побеждает остальные источники —
|
||||||
string FreeLine1 = "",
|
/// это самый явный выбор из всех, его не подставляют молча.
|
||||||
string FreeLine2 = ""
|
/// </summary>
|
||||||
|
string? ClipFile = null,
|
||||||
|
/// <summary>
|
||||||
|
/// Брать звук из самого ролика. Ставится, когда у блока нет джингла: подкладывать под чужую
|
||||||
|
/// заставку синтезированный писк хуже, чем оставить её собственную дорожку.
|
||||||
|
/// </summary>
|
||||||
|
bool UseClipAudio = false
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
|
/// <summary>Итог рендера заставки — та же форма метаданных, что у обычного ассета после нарезки.</summary>
|
||||||
@@ -39,9 +48,9 @@ public sealed record BumperRenderResult(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + текст
|
/// Порт рендера ТВ-заставок. Реализация синтезирует короткий клип (анимированный фон + строки текста
|
||||||
/// «Сейчас/Далее» + джингл) по <see cref="BumperRenderSpec"/> и режет его на HLS-сегменты в
|
/// + джингл) по <see cref="BumperRenderSpec"/> и режет его на HLS-сегменты в assets/{assetId} — так
|
||||||
/// assets/{assetId} — так же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
|
/// же, как обычный ассет, чтобы раздача эфира не отличала заставку от программы.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IBumperRenderer
|
public interface IBumperRenderer
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Куда ложится копия отладочного дампа на сервере. Сам архив всегда уезжает в браузер — это порт
|
||||||
|
/// для второй копии, которая остаётся рядом с репозиторием: разбирать «почему сетка построилась
|
||||||
|
/// вот так» приходится вместе с кодом, и перекладывать архивы руками из загрузок каждый раз —
|
||||||
|
/// лишний шаг.
|
||||||
|
///
|
||||||
|
/// Каталог задаётся настройкой и по умолчанию пуст: на сервере писать дампы на диск незачем,
|
||||||
|
/// это инструмент разработки.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDebugExportStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Сохраняет архив. Возвращает путь, куда он лёг, либо null — сохранение выключено настройкой.
|
||||||
|
/// Ошибка записи наружу не идёт: дамп уже собран, и терять его из-за недоступной папки нельзя.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> SaveAsync(string fileName, byte[] content, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Живые проверки инфраструктуры для админской диагностики: свободное место на томе хранилища и
|
||||||
|
/// доступность внешних инструментов (ffmpeg/ffprobe). Отдельный порт — чтобы Application не ходил
|
||||||
|
/// в файловую систему и процессы напрямую: это забота Infrastructure.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDiagnosticsProbe
|
||||||
|
{
|
||||||
|
Task<StorageProbe> ProbeStorageAsync(CancellationToken cancellationToken);
|
||||||
|
Task<ToolProbe> ProbeFfmpegAsync(CancellationToken cancellationToken);
|
||||||
|
Task<ToolProbe> ProbeFfprobeAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Состояние тома хранилища. Нули в размерах — ФС не отдала метрику (экзотический маунт).</summary>
|
||||||
|
public sealed record StorageProbe(
|
||||||
|
string RootPath,
|
||||||
|
bool Exists,
|
||||||
|
bool Writable,
|
||||||
|
long TotalBytes,
|
||||||
|
long FreeBytes,
|
||||||
|
long MinFreeBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Доступность внешнего инструмента и его версия (первая строка вывода <c>-version</c>).</summary>
|
||||||
|
public sealed record ToolProbe(bool Available, string? Version);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пропускная способность обработчика медиа. Нужна, чтобы по очереди и среднему времени посчитать,
|
||||||
|
/// когда она разгребётся: при двух параллельных транскодах ждать вдвое меньше, чем при одном.
|
||||||
|
///
|
||||||
|
/// Порт, а не чтение настроек напрямую: настройки ffmpeg живут в Infrastructure, и прикладной слой
|
||||||
|
/// про их устройство знать не должен.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMediaProcessingLimits
|
||||||
|
{
|
||||||
|
/// <summary>Сколько файлов обрабатывается одновременно.</summary>
|
||||||
|
int MaxParallelTranscodes { get; }
|
||||||
|
}
|
||||||
@@ -1,63 +1,70 @@
|
|||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
|
|
||||||
namespace TeleWave.Application.Common.Interfaces;
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
|
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
|
||||||
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
|
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMediaStorage
|
public interface IMediaStorage
|
||||||
{
|
{
|
||||||
/// <summary>Свободное место на томе хранилища, байт.</summary>
|
/// <summary>Свободное место на томе хранилища, байт.</summary>
|
||||||
long GetAvailableFreeSpaceBytes();
|
long GetAvailableFreeSpaceBytes();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
|
/// Абсолютный путь к HLS-плейлисту готового ассета (<c>assets/{id}/index.m3u8</c>) — им ffmpeg
|
||||||
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
|
/// читает уже нарезанное медиа как обычный вход. Null, если каталога ассета нет на диске.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string> SaveUploadAsync(
|
string? ResolvePlaylistPath(Guid assetId);
|
||||||
Stream content,
|
|
||||||
string extension,
|
/// <summary>
|
||||||
CancellationToken cancellationToken
|
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
|
||||||
);
|
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
|
||||||
|
/// </summary>
|
||||||
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
|
Task<string> SaveUploadAsync(
|
||||||
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
|
Stream content,
|
||||||
|
string extension,
|
||||||
/// <summary>Файл ручного inbox.</summary>
|
CancellationToken cancellationToken
|
||||||
/// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
|
);
|
||||||
public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
|
|
||||||
|
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
|
||||||
/// <summary>
|
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
|
||||||
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
|
|
||||||
/// каталог наполняет человек, и он может оказаться большим.
|
/// <summary>Файл ручного inbox.</summary>
|
||||||
/// </summary>
|
/// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
|
||||||
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
|
public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
|
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
|
||||||
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
|
/// каталог наполняет человек, и он может оказаться большим.
|
||||||
/// не трогает — рядом может лежать следующая серия.
|
/// </summary>
|
||||||
/// </summary>
|
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
|
||||||
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
|
|
||||||
|
/// <summary>
|
||||||
/// <summary>
|
/// Убирает то, что осталось в <c>manual/</c> от забранного файла: спутники с тем же именем
|
||||||
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
|
/// и другим расширением (субтитры, nfo, обложка), файлы того же эпизода в соседних подпапках
|
||||||
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
|
/// (<c>subs_ru</c>, <c>Subtitles</c>) и опустевшие каталоги вверх до корня <c>manual/</c>.
|
||||||
/// переносит — файл из каталога-источника уходит.
|
/// Другие видеофайлы не трогает — рядом может лежать следующая серия или иная версия.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task PromoteToOriginalAsync(
|
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
|
||||||
MediaSource source,
|
|
||||||
string sourceToken,
|
/// <summary>
|
||||||
Guid assetId,
|
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
|
||||||
string extension,
|
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
|
||||||
CancellationToken cancellationToken
|
/// переносит — файл из каталога-источника уходит.
|
||||||
);
|
/// </summary>
|
||||||
|
Task PromoteToOriginalAsync(
|
||||||
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
|
MediaSource source,
|
||||||
Task DeleteAssetArtifactsAsync(
|
string sourceToken,
|
||||||
Guid assetId,
|
Guid assetId,
|
||||||
string extension,
|
string extension,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
|
||||||
|
Task DeleteAssetArtifactsAsync(
|
||||||
|
Guid assetId,
|
||||||
|
string extension,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ public interface IMetadataProvider
|
|||||||
int season,
|
int season,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Номера сезонов сериала по данным источника (пусто — источник не отдал данные). Спецвыпуски
|
||||||
|
/// (нулевой сезон) не отдаются: в библиотеку они не грузятся и считались бы вечно отсутствующими.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<int>> GetSeasonNumbersAsync(
|
||||||
|
string externalId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Резолвит провайдер по ключу и перечисляет реально настроенные (с API-ключом) источники.</summary>
|
/// <summary>Резолвит провайдер по ключу и перечисляет реально настроенные (с API-ключом) источники.</summary>
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
namespace TeleWave.Application.Common.Interfaces;
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
|
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
|
||||||
public interface ISiteSettings
|
public interface ISiteSettings
|
||||||
{
|
{
|
||||||
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
|
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
|
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
|
||||||
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||||
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
|
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
|
||||||
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
|
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
|
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
|
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
|
||||||
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
|
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Части хранилища, которые считаются отдельно. Это не «папки», а ответ на вопрос «чем занят диск»:
|
||||||
|
/// сегменты программ и сегменты заставок лежат в одном каталоге, но растут по разным причинам,
|
||||||
|
/// и администратору важно видеть их порознь.
|
||||||
|
/// </summary>
|
||||||
|
public enum StorageArea
|
||||||
|
{
|
||||||
|
/// <summary>HLS-сегменты программ — обычно это почти весь объём.</summary>
|
||||||
|
Assets = 0,
|
||||||
|
|
||||||
|
/// <summary>HLS-сегменты отрендеренных заставок.</summary>
|
||||||
|
BumperAssets = 1,
|
||||||
|
|
||||||
|
/// <summary>Исходники, оставленные после нарезки (<c>Storage:KeepOriginals</c>).</summary>
|
||||||
|
Originals = 2,
|
||||||
|
|
||||||
|
/// <summary>Автоматический inbox — то, что ещё не разобрано сканером.</summary>
|
||||||
|
Inbox = 3,
|
||||||
|
|
||||||
|
/// <summary>Ручной inbox — то, что админ забирает в шоу сам.</summary>
|
||||||
|
ManualInbox = 4,
|
||||||
|
|
||||||
|
/// <summary>Перевалочный каталог заливок: всё, что здесь залежалось, — след оборванной загрузки.</summary>
|
||||||
|
Uploads = 5,
|
||||||
|
|
||||||
|
/// <summary>Звук и фоны блоков заставок (сырые файлы, не нарезка).</summary>
|
||||||
|
BumperSources = 6,
|
||||||
|
|
||||||
|
/// <summary>Общий реестр изображений: постеры, кадры, логотипы.</summary>
|
||||||
|
Images = 7,
|
||||||
|
|
||||||
|
/// <summary>Всё прочее под корнем хранилища — обычно ноль, а если нет, то это и надо увидеть.</summary>
|
||||||
|
Other = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="Bytes">Объём области, байт.</param>
|
||||||
|
/// <param name="Files">Число файлов — по нему видно, что «пусто» и «много мелочи» это разные беды.</param>
|
||||||
|
public sealed record StorageAreaUsage(StorageArea Area, long Bytes, int Files);
|
||||||
|
|
||||||
|
/// <param name="RootPath">Корень хранилища — на сервере их может быть несколько, и надо видеть, о каком речь.</param>
|
||||||
|
/// <param name="VolumeTotalBytes">Размер тома целиком; 0 — файловая система не отдала метрику.</param>
|
||||||
|
/// <param name="VolumeFreeBytes">Свободно на томе. Свободное место делится со всем, что есть на диске.</param>
|
||||||
|
/// <param name="MinFreeSpaceBytes">Порог, ниже которого загрузка отклоняется; едет вместе с цифрами тома, чтобы их было с чем сравнить.</param>
|
||||||
|
/// <param name="ComputedAt">Когда посчитано: обход дерева кэшируется, и возраст цифры надо показывать.</param>
|
||||||
|
public sealed record StorageUsage(
|
||||||
|
string RootPath,
|
||||||
|
long VolumeTotalBytes,
|
||||||
|
long VolumeFreeBytes,
|
||||||
|
long StorageBytes,
|
||||||
|
int StorageFiles,
|
||||||
|
long MinFreeSpaceBytes,
|
||||||
|
IReadOnlyList<StorageAreaUsage> Areas,
|
||||||
|
DateTimeOffset ComputedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Порт подсчёта занятого места. Размеров в БД нет и не будет: правда о диске лежит на диске,
|
||||||
|
/// а любое дублирование в таблицах разошлось бы с ней при первой же ручной правке файлов.
|
||||||
|
///
|
||||||
|
/// Обход дерева стоит дорого (сегментов — сотни тысяч), поэтому результат кэшируется реализацией,
|
||||||
|
/// а <paramref name="refresh"/> — это явная кнопка «пересчитать» в админке.
|
||||||
|
/// </summary>
|
||||||
|
public interface IStorageInspector
|
||||||
|
{
|
||||||
|
Task<StorageUsage> InspectAsync(
|
||||||
|
IReadOnlySet<Guid> generatedAssetIds,
|
||||||
|
bool refresh,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using TeleWave.Domain.Notifications;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Кнопка инлайн-клавиатуры: подпись и что вернётся в callback.</summary>
|
||||||
|
public sealed record TelegramButton(string Text, string CallbackData);
|
||||||
|
|
||||||
|
/// <summary>Обновление от Telegram — только то, что нам нужно от его модели.</summary>
|
||||||
|
public sealed record TelegramUpdate(
|
||||||
|
long UpdateId,
|
||||||
|
long? ChatId,
|
||||||
|
string? Text,
|
||||||
|
string? From,
|
||||||
|
/// <summary>Идентификатор нажатия: на него надо ответить, иначе кнопка «крутится» у зрителя.</summary>
|
||||||
|
string? CallbackId,
|
||||||
|
string? CallbackData,
|
||||||
|
long? CallbackMessageId
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Кто мы для Telegram — ответ getMe, по нему проверяется токен.</summary>
|
||||||
|
public sealed record TelegramBotInfo(long Id, string Username);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Порт к Bot API. Держим ровно те вызовы, что нужны боту расписания: узнать себя, получить
|
||||||
|
/// обновления, ответить сообщением с кнопками и переключить вебхук.
|
||||||
|
///
|
||||||
|
/// Настройки (токен, прокси) реализация читает сама на каждый вызов: админ меняет их в рантайме,
|
||||||
|
/// и держать клиента, собранного на старте, значило бы требовать перезапуск контейнера.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITelegramApi
|
||||||
|
{
|
||||||
|
Task<TelegramBotInfo?> GetMeAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<TelegramUpdate>> GetUpdatesAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
long offset,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Отправляет сообщение и возвращает его идентификатор — по нему потом правят и удаляют.</summary>
|
||||||
|
Task<long?> SendMessageAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
long chatId,
|
||||||
|
string text,
|
||||||
|
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Переписывает уже отправленное сообщение — так меню подписок обновляется на месте.</summary>
|
||||||
|
Task EditMessageAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
long chatId,
|
||||||
|
long messageId,
|
||||||
|
string text,
|
||||||
|
IReadOnlyList<IReadOnlyList<TelegramButton>>? keyboard,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Снимает своё сообщение. В личном чате бот вправе удалять только собственные — команды
|
||||||
|
/// зрителя остаются, и это не в нашей власти.
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteMessageAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
long chatId,
|
||||||
|
long messageId,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Гасит «часики» на нажатой кнопке; текст — всплывающая подсказка.</summary>
|
||||||
|
Task AnswerCallbackAsync(
|
||||||
|
TelegramSettings settings,
|
||||||
|
string callbackId,
|
||||||
|
string? text,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Разбирает тело вебхука в то же обновление, что приходит опросом: разговор с ботом один,
|
||||||
|
/// и знать про две модели ему незачем. null — тело не разобралось.
|
||||||
|
/// </summary>
|
||||||
|
Task<TelegramUpdate?> ReadUpdateAsync(Stream body, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task SetWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task DeleteWebhookAsync(TelegramSettings settings, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Склейка ответа на вопрос «где это используется» для текста ошибки. Отказ вида «объект занят»
|
||||||
|
/// без адреса заставляет обходить все экраны подряд, а перечисление держателей превращает ошибку
|
||||||
|
/// в инструкцию: видно, что именно отвязать.
|
||||||
|
/// </summary>
|
||||||
|
public static class UsageText
|
||||||
|
{
|
||||||
|
/// <summary>Сколько имён показываем: длинный список в тосте всё равно не читается.</summary>
|
||||||
|
private const int MaxNames = 5;
|
||||||
|
|
||||||
|
/// <summary>Раздел перечисления: «слоты каналов: Первый, Второй». Пустой список — пропускается.</summary>
|
||||||
|
public static string? Part(string what, IReadOnlyCollection<string> names)
|
||||||
|
{
|
||||||
|
if (names.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var shown = string.Join(", ", names.Take(MaxNames));
|
||||||
|
return names.Count > MaxNames
|
||||||
|
? $"{what}: {shown} и ещё {names.Count - MaxNames}"
|
||||||
|
: $"{what}: {shown}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Собирает разделы в одну строку; пустые отбрасываются.</summary>
|
||||||
|
public static string Join(params string?[] parts) =>
|
||||||
|
string.Join("; ", parts.Where(p => !string.IsNullOrWhiteSpace(p)));
|
||||||
|
}
|
||||||
@@ -5,10 +5,21 @@ using LiteCqrs.DependencyInjection;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Common.Behaviors;
|
using TeleWave.Application.Common.Behaviors;
|
||||||
|
using TeleWave.Application.Library.Collections;
|
||||||
|
using TeleWave.Application.Library.Collections.Suggest;
|
||||||
using TeleWave.Application.Library.Genres;
|
using TeleWave.Application.Library.Genres;
|
||||||
|
using TeleWave.Application.Media;
|
||||||
|
using TeleWave.Application.Media.ManualInbox;
|
||||||
|
using TeleWave.Application.Media.MovieImport;
|
||||||
|
using TeleWave.Application.Metadata;
|
||||||
|
using TeleWave.Application.Notifications;
|
||||||
|
using TeleWave.Application.Notifications.Settings;
|
||||||
using TeleWave.Application.Programming.Groups;
|
using TeleWave.Application.Programming.Groups;
|
||||||
|
using TeleWave.Application.Programming.Groups.Suggest;
|
||||||
using TeleWave.Application.Programming.Planning;
|
using TeleWave.Application.Programming.Planning;
|
||||||
|
using TeleWave.Application.Programming.Planning.DebugExport;
|
||||||
using TeleWave.Application.Programming.Templates;
|
using TeleWave.Application.Programming.Templates;
|
||||||
|
using TeleWave.Application.Programming.Templates.Generate;
|
||||||
|
|
||||||
namespace TeleWave.Application;
|
namespace TeleWave.Application;
|
||||||
|
|
||||||
@@ -35,14 +46,31 @@ public static class DependencyInjection
|
|||||||
// здесь, а не в Infrastructure: тот слой не должен знать внутреннего устройства Application.
|
// здесь, а не в Infrastructure: тот слой не должен знать внутреннего устройства Application.
|
||||||
services.AddScoped<BumperSpecLoader>();
|
services.AddScoped<BumperSpecLoader>();
|
||||||
services.AddScoped<GenreMatcher>();
|
services.AddScoped<GenreMatcher>();
|
||||||
|
services.AddScoped<ShowMetadataApplier>();
|
||||||
|
services.AddScoped<MovieMatcher>();
|
||||||
|
services.AddScoped<ManualInboxTaker>();
|
||||||
|
services.AddScoped<ShowMediaEraser>();
|
||||||
|
services.AddScoped<CollectionSuggestionBuilder>();
|
||||||
|
services.AddScoped<CollectionPosterPicker>();
|
||||||
services.AddScoped<GroupElementResolver>();
|
services.AddScoped<GroupElementResolver>();
|
||||||
|
services.AddScoped<GroupFilterMatcher>();
|
||||||
|
services.AddScoped<DynamicGroupResolver>();
|
||||||
services.AddScoped<GroupStatsService>();
|
services.AddScoped<GroupStatsService>();
|
||||||
|
services.AddScoped<GroupSuggestionBuilder>();
|
||||||
services.AddScoped<GroupMembershipCleaner>();
|
services.AddScoped<GroupMembershipCleaner>();
|
||||||
|
services.AddScoped<InterstitialGroups>();
|
||||||
services.AddScoped<SlotWriter>();
|
services.AddScoped<SlotWriter>();
|
||||||
|
services.AddScoped<GroupCatalog>();
|
||||||
|
services.AddScoped<GridPlanner>();
|
||||||
services.AddScoped<GroupExpander>();
|
services.AddScoped<GroupExpander>();
|
||||||
services.AddScoped<BumperResolver>();
|
services.AddScoped<BumperResolver>();
|
||||||
services.AddScoped<PostCheckRunner>();
|
services.AddScoped<PostCheckRunner>();
|
||||||
services.AddScoped<GridScheduleGenerator>();
|
services.AddScoped<GridScheduleGenerator>();
|
||||||
|
services.AddScoped<ChannelDebugCollector>();
|
||||||
|
services.AddScoped<LibraryDebugCollector>();
|
||||||
|
services.AddScoped<TelegramSettingsService>();
|
||||||
|
services.AddScoped<TelegramBotService>();
|
||||||
|
services.AddScoped<TelegramNotifier>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>Живое состояние инфраструктуры для админской панели диагностики: БД, хранилище, ffmpeg.</summary>
|
||||||
|
public sealed record GetSystemDiagnosticsQuery : IQuery<SystemDiagnosticsDto>;
|
||||||
|
|
||||||
|
public sealed record SystemDiagnosticsDto(
|
||||||
|
DatabaseDiagnosticsDto Database,
|
||||||
|
StorageDiagnosticsDto Storage,
|
||||||
|
ToolDiagnosticsDto Ffmpeg,
|
||||||
|
ToolDiagnosticsDto Ffprobe
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>Доступность БД и время лёгкого запроса; при ошибке — её текст без latency.</summary>
|
||||||
|
public sealed record DatabaseDiagnosticsDto(bool Reachable, long? LatencyMs, string? Error);
|
||||||
|
|
||||||
|
/// <summary><c>LowSpace</c> — свободного меньше порога <c>MinFreeBytes</c> (место известно и оно мало).</summary>
|
||||||
|
public sealed record StorageDiagnosticsDto(
|
||||||
|
string RootPath,
|
||||||
|
bool Exists,
|
||||||
|
bool Writable,
|
||||||
|
long TotalBytes,
|
||||||
|
long FreeBytes,
|
||||||
|
long MinFreeBytes,
|
||||||
|
bool LowSpace
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ToolDiagnosticsDto(bool Available, string? Version);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Diagnostics;
|
||||||
|
|
||||||
|
public sealed class GetSystemDiagnosticsQueryHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
IDiagnosticsProbe probe
|
||||||
|
) : IQueryHandler<GetSystemDiagnosticsQuery, SystemDiagnosticsDto>
|
||||||
|
{
|
||||||
|
public async Task<SystemDiagnosticsDto> Handle(
|
||||||
|
GetSystemDiagnosticsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var database = await CheckDatabaseAsync(cancellationToken);
|
||||||
|
var storageProbe = await probe.ProbeStorageAsync(cancellationToken);
|
||||||
|
var ffmpeg = await probe.ProbeFfmpegAsync(cancellationToken);
|
||||||
|
var ffprobe = await probe.ProbeFfprobeAsync(cancellationToken);
|
||||||
|
|
||||||
|
var storage = new StorageDiagnosticsDto(
|
||||||
|
storageProbe.RootPath,
|
||||||
|
storageProbe.Exists,
|
||||||
|
storageProbe.Writable,
|
||||||
|
storageProbe.TotalBytes,
|
||||||
|
storageProbe.FreeBytes,
|
||||||
|
storageProbe.MinFreeBytes,
|
||||||
|
LowSpace: storageProbe.FreeBytes > 0
|
||||||
|
&& storageProbe.FreeBytes < storageProbe.MinFreeBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
return new SystemDiagnosticsDto(
|
||||||
|
database,
|
||||||
|
storage,
|
||||||
|
new ToolDiagnosticsDto(ffmpeg.Available, ffmpeg.Version),
|
||||||
|
new ToolDiagnosticsDto(ffprobe.Available, ffprobe.Version)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Лёгкая живая проверка БД: тривиальный запрос под секундомер. Не полагаемся на стартовый
|
||||||
|
/// health-check — админ хочет видеть состояние прямо сейчас. Ловим <see cref="DbException"/>
|
||||||
|
/// (база провайдера Npgsql-исключений) — так Application не тянет ссылку на Npgsql.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<DatabaseDiagnosticsDto> CheckDatabaseAsync(
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await dbContext.AppSettings.AnyAsync(cancellationToken);
|
||||||
|
stopwatch.Stop();
|
||||||
|
return new DatabaseDiagnosticsDto(true, stopwatch.ElapsedMilliseconds, null);
|
||||||
|
}
|
||||||
|
catch (DbException ex)
|
||||||
|
{
|
||||||
|
return new DatabaseDiagnosticsDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.BulkTagShows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проставляет жанры и/или возрастной рейтинг сразу нескольким шоу. Оба поля необязательны и
|
||||||
|
/// применяются независимо: обычно правят что-то одно, а связывать их в одну форму значило бы
|
||||||
|
/// заставлять переставлять второе «как было».
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="GenreIds">
|
||||||
|
/// Что делать с жанрами: null — не трогать. Иначе <paramref name="ReplaceGenres"/> решает, заменить
|
||||||
|
/// набор целиком или дописать к имеющимся.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ReplaceGenres">
|
||||||
|
/// true — заменить набор жанров переданным; false — дописать. Дописывание нужно чаще: у шоу уже
|
||||||
|
/// есть жанры из метаданных, и разметка «это ещё и детское» не должна их стирать.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Audience">Рейтинг: null со <paramref name="SetAudience"/> = true снимает проставленный.</param>
|
||||||
|
/// <param name="SetAudience">Трогать ли рейтинг вообще.</param>
|
||||||
|
public sealed record BulkTagShowsCommand(
|
||||||
|
IReadOnlyList<Guid> ShowIds,
|
||||||
|
IReadOnlyList<Guid>? GenreIds = null,
|
||||||
|
bool ReplaceGenres = false,
|
||||||
|
ShowAudience? Audience = null,
|
||||||
|
bool SetAudience = false
|
||||||
|
) : ICommand<Result<int>>;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Application.Library.Genres;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.BulkTagShows;
|
||||||
|
|
||||||
|
public sealed class BulkTagShowsCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<BulkTagShowsCommand, Result<int>>
|
||||||
|
{
|
||||||
|
public async Task<Result<int>> Handle(
|
||||||
|
BulkTagShowsCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var showIds = command.ShowIds.Distinct().ToList();
|
||||||
|
if (showIds.Count == 0)
|
||||||
|
return Result.Success(0);
|
||||||
|
|
||||||
|
var genreIds = command.GenreIds?.Distinct().ToList();
|
||||||
|
if (genreIds is { Count: > 0 })
|
||||||
|
{
|
||||||
|
// Ссылка на несуществующий жанр упала бы нарушением внешнего ключа — проверяем заранее.
|
||||||
|
var known = await dbContext
|
||||||
|
.Genres.Where(g => genreIds.Contains(g.Id))
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
if (known != genreIds.Count)
|
||||||
|
return Result.Failure<int>(GenreErrors.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
var shows = await dbContext
|
||||||
|
.Shows.Include(s => s.Genres)
|
||||||
|
.Where(s => showIds.Contains(s.Id))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Пропавшее шоу не роняет всю операцию: список выбирали галочками, и пока его собирали,
|
||||||
|
// что-то могли удалить. Возвращаем, скольких реально коснулись.
|
||||||
|
foreach (var show in shows)
|
||||||
|
{
|
||||||
|
if (genreIds is not null)
|
||||||
|
ApplyGenres(show, genreIds, command.ReplaceGenres);
|
||||||
|
|
||||||
|
if (command.SetAudience)
|
||||||
|
show.SetAudience(command.Audience);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success(shows.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Основным остаётся прежний основной жанр — массовая разметка не должна незаметно менять то,
|
||||||
|
/// что показано в списке шоу. Если его не было (жанров не было вовсе), основным станет первый
|
||||||
|
/// из добавленных, как и при обычной правке.
|
||||||
|
/// </summary>
|
||||||
|
private static void ApplyGenres(
|
||||||
|
Domain.Library.Show show,
|
||||||
|
IReadOnlyList<Guid> genreIds,
|
||||||
|
bool replace
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var primary = show.PrimaryGenreId;
|
||||||
|
var ids = replace ? genreIds : show.Genres.Select(g => g.GenreId).Union(genreIds).ToList();
|
||||||
|
show.SetGenres(ids, ids.Contains(primary ?? Guid.Empty) ? primary : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.BulkTagShows;
|
||||||
|
|
||||||
|
public sealed class BulkTagShowsCommandValidator : AbstractValidator<BulkTagShowsCommand>
|
||||||
|
{
|
||||||
|
public BulkTagShowsCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ShowIds).NotEmpty();
|
||||||
|
// Команда, которая ничего не меняет, — почти наверняка недосмотр вызывающей стороны.
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => x.GenreIds is not null || x.SetAudience)
|
||||||
|
.WithMessage("Нечего применять: не заданы ни жанры, ни рейтинг.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Отправляет несколько шоу во франшизу разом. Коллекция либо уже есть
|
||||||
|
/// (<paramref name="CollectionId"/>), либо заводится по имени
|
||||||
|
/// (<paramref name="NewCollectionName"/>) — иначе на сборку франшизы уходило бы два экрана.
|
||||||
|
///
|
||||||
|
/// Порядок добавления — по году выпуска: сиквелы почти всегда идут так, а исключения правятся
|
||||||
|
/// перетаскиванием в карточке коллекции.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AddShowsToCollectionCommand(
|
||||||
|
IReadOnlyList<Guid> ShowIds,
|
||||||
|
Guid? CollectionId = null,
|
||||||
|
string? NewCollectionName = null
|
||||||
|
) : ICommand<Result<AddShowsToCollectionResultDto>>;
|
||||||
|
|
||||||
|
/// <param name="CollectionId">Куда в итоге сложили — новая коллекция или переданная.</param>
|
||||||
|
/// <param name="Added">Сколько шоу реально добавлено (уже входящие пропускаются).</param>
|
||||||
|
public sealed record AddShowsToCollectionResultDto(Guid CollectionId, string Name, int Added);
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
|
||||||
|
|
||||||
|
public sealed class AddShowsToCollectionCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<AddShowsToCollectionCommand, Result<AddShowsToCollectionResultDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<AddShowsToCollectionResultDto>> Handle(
|
||||||
|
AddShowsToCollectionCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var showIds = command.ShowIds.Distinct().ToList();
|
||||||
|
var shows = await dbContext
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => showIds.Contains(s.Id))
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.Id,
|
||||||
|
s.Name,
|
||||||
|
s.Year,
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (shows.Count == 0)
|
||||||
|
return Result.Failure<AddShowsToCollectionResultDto>(ShowErrors.NotFound);
|
||||||
|
|
||||||
|
var collection = await ResolveCollectionAsync(command, cancellationToken);
|
||||||
|
if (collection is null)
|
||||||
|
return Result.Failure<AddShowsToCollectionResultDto>(CollectionErrors.NotFound);
|
||||||
|
|
||||||
|
// Год — главный порядок франшизы; без года часть уходит в конец, там её и переставят руками.
|
||||||
|
var ordered = shows
|
||||||
|
.OrderBy(s => s.Year is null)
|
||||||
|
.ThenBy(s => s.Year)
|
||||||
|
.ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase);
|
||||||
|
|
||||||
|
var added = ordered.Count(show => collection.AddShow(show.Id) is not null);
|
||||||
|
|
||||||
|
return Result.Success(
|
||||||
|
new AddShowsToCollectionResultDto(collection.Id, collection.Name, added)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Collection?> ResolveCollectionAsync(
|
||||||
|
AddShowsToCollectionCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (command.CollectionId is { } id)
|
||||||
|
return await dbContext
|
||||||
|
.Collections.Include(c => c.Items)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
var created = Collection.Create(command.NewCollectionName!);
|
||||||
|
dbContext.Collections.Add(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Collections.AddShowsToCollection;
|
||||||
|
|
||||||
|
public sealed class AddShowsToCollectionCommandValidator
|
||||||
|
: AbstractValidator<AddShowsToCollectionCommand>
|
||||||
|
{
|
||||||
|
public AddShowsToCollectionCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.ShowIds).NotEmpty();
|
||||||
|
// Либо кладём в существующую, либо заводим новую — «ни то ни другое» указывает на ошибку UI.
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x =>
|
||||||
|
x.CollectionId is not null || !string.IsNullOrWhiteSpace(x.NewCollectionName)
|
||||||
|
)
|
||||||
|
.WithMessage("Не указано, в какую коллекцию складывать.");
|
||||||
|
RuleFor(x => x.NewCollectionName).MaximumLength(256);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,4 +18,14 @@ public static class CollectionErrors
|
|||||||
"Collections.ShowNotInCollection",
|
"Collections.ShowNotInCollection",
|
||||||
"Шоу не входит в коллекцию."
|
"Шоу не входит в коллекцию."
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static readonly Error SuggestionNotFound = Error.NotFound(
|
||||||
|
"Collections.SuggestionNotFound",
|
||||||
|
"Предложение устарело — библиотека изменилась. Обновите список предложений."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly Error SuggestionAlreadyCreated = Error.Conflict(
|
||||||
|
"Collections.SuggestionAlreadyCreated",
|
||||||
|
"Коллекция с таким названием уже есть."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Collections;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подбирает коллекции постер по её частям: берётся первая часть с постером — обычно это первый
|
||||||
|
/// фильм цикла, и он же узнаётся с обложки. Отдельный тип, потому что подбор нужен и при создании
|
||||||
|
/// коллекции, и по кнопке для уже заведённых.
|
||||||
|
///
|
||||||
|
/// Своего изображения коллекция не заводит: постер — это тот же <see cref="Image"/> из реестра,
|
||||||
|
/// на который ссылается шоу. Копировать файл ради второй ссылки незачем.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CollectionPosterPicker(IAppDbContext dbContext)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Проставляет постер коллекциям, у которых его нет. Уже выбранный не трогаем: его мог
|
||||||
|
/// поставить человек, и перебивать такой выбор автоматикой нельзя. Возвращает, скольким
|
||||||
|
/// коллекциям постер нашёлся.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> FillAsync(
|
||||||
|
IReadOnlyList<Collection> collections,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var pending = collections.Where(c => c.PosterImageId is null).ToList();
|
||||||
|
if (pending.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var posters = await PostersAsync(pending, cancellationToken);
|
||||||
|
var filled = 0;
|
||||||
|
|
||||||
|
foreach (var collection in pending)
|
||||||
|
{
|
||||||
|
if (Pick(collection, posters) is not { } imageId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
collection.SetPosterImage(imageId);
|
||||||
|
filled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Постер первой по порядку части, у которой он есть.</summary>
|
||||||
|
private static Guid? Pick(Collection collection, IReadOnlyDictionary<Guid, Guid> posters) =>
|
||||||
|
collection
|
||||||
|
.Items.OrderBy(i => i.Position)
|
||||||
|
.Select(i => posters.TryGetValue(i.ShowId, out var image) ? image : (Guid?)null)
|
||||||
|
.FirstOrDefault(image => image is not null);
|
||||||
|
|
||||||
|
/// <summary>Постеры шоу, входящих в эти коллекции, одним запросом на всю пачку.</summary>
|
||||||
|
private async Task<Dictionary<Guid, Guid>> PostersAsync(
|
||||||
|
IReadOnlyList<Collection> collections,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var showIds = collections
|
||||||
|
.SelectMany(c => c.Items.Select(i => i.ShowId))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var shows = await dbContext
|
||||||
|
.Shows.AsNoTracking()
|
||||||
|
.Where(s => showIds.Contains(s.Id) && s.PosterImageId != null)
|
||||||
|
.Select(s => new { s.Id, PosterImageId = s.PosterImageId!.Value })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return shows.ToDictionary(s => s.Id, s => s.PosterImageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Collections.FillCollectionPosters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проставляет постеры коллекциям по их частям. Пустой список — всем коллекциям без постера:
|
||||||
|
/// после массового импорта фильмов их сразу десятки, и обходить каждую руками бессмысленно.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FillCollectionPostersCommand(IReadOnlyList<Guid>? CollectionIds = null)
|
||||||
|
: ICommand<Result<int>>;
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
using LiteCqrs;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace TeleWave.Application.Library.Collections.FillCollectionPosters;
|
||||||
|
|
||||||
|
public sealed class FillCollectionPostersCommandHandler(
|
||||||
|
IAppDbContext dbContext,
|
||||||
|
CollectionPosterPicker picker
|
||||||
|
) : ICommandHandler<FillCollectionPostersCommand, Result<int>>
|
||||||
|
{
|
||||||
|
public async Task<Result<int>> Handle(
|
||||||
|
FillCollectionPostersCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var query = dbContext.Collections.Include(c => c.Items).AsQueryable();
|
||||||
|
|
||||||
|
if (command.CollectionIds is { Count: > 0 } ids)
|
||||||
|
query = query.Where(c => ids.Contains(c.Id));
|
||||||
|
|
||||||
|
var collections = await query.ToListAsync(cancellationToken);
|
||||||
|
var filled = await picker.FillAsync(collections, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(filled);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user