diff --git a/.gitea/workflows/ci-api.yml b/.gitea/workflows/ci-api.yml index d2cb77e..1450a3d 100644 --- a/.gitea/workflows/ci-api.yml +++ b/.gitea/workflows/ci-api.yml @@ -1,4 +1,4 @@ -# Go: сервис api и storage-agent (docs/ci-cd.md). Только при изменении их кода или контракта. +# Go: сервис api и storage-agent. Только при изменении их кода или контракта. name: ci / api on: diff --git a/.gitea/workflows/ci-contract.yml b/.gitea/workflows/ci-contract.yml index 163e96c..afdda24 100644 --- a/.gitea/workflows/ci-contract.yml +++ b/.gitea/workflows/ci-contract.yml @@ -1,4 +1,4 @@ -# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md). +# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками. name: ci / contract on: diff --git a/.gitea/workflows/ci-mobile.yml b/.gitea/workflows/ci-mobile.yml index 43c2523..b99b089 100644 --- a/.gitea/workflows/ci-mobile.yml +++ b/.gitea/workflows/ci-mobile.yml @@ -1,4 +1,4 @@ -# Expo: expo-doctor и Metro-бандл Android (docs/ci-cd.md). Полная сборка APK — только в release.yml. +# Expo: expo-doctor и Metro-бандл Android. Полная сборка APK — только в release.yml. name: ci / mobile on: diff --git a/.gitea/workflows/ci-recommender.yml b/.gitea/workflows/ci-recommender.yml index 41dcd6b..4a9b0a8 100644 --- a/.gitea/workflows/ci-recommender.yml +++ b/.gitea/workflows/ci-recommender.yml @@ -1,4 +1,4 @@ -# Python: recommender и analyzer (docs/ci-cd.md). +# Python: recommender и analyzer. name: ci / recommender on: diff --git a/.gitea/workflows/ci-ts.yml b/.gitea/workflows/ci-ts.yml index 3f13014..4833132 100644 --- a/.gitea/workflows/ci-ts.yml +++ b/.gitea/workflows/ci-ts.yml @@ -1,4 +1,4 @@ -# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза (docs/ci-cd.md). +# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза. name: ci / ts on: diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 4d1f35b..29198dd 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -49,7 +49,7 @@ jobs: - uses: actions/checkout@v4 # Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и # --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги - # не дают копиться образам; кэш сборки чистит на хосте `docker builder prune` (docs/ci-cd.md). + # не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`. - name: Сборка образов env: { DOCKER_BUILDKIT: "1" } run: | diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index bd2c9c1..d3ab406 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,4 +1,4 @@ -# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): Gitea Release с заметками → APK. +# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK. # Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы # и переписывает статус: собран / не удалась (scripts/release.mjs). # Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh). @@ -53,7 +53,7 @@ jobs: runs-on: ubuntu-latest # Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK # переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома - # (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново. + # (container.valid_volumes: ['/opt/ci-cache/**']); иначе раннер их пропускает и всё качается заново. # с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail) defaults: run: diff --git a/README.md b/README.md index 909ee5f..7354bf4 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения. - Идея продукта: [docs/IDEA.md](docs/IDEA.md) -- Документация для разработки: [docs/README.md](docs/README.md) -- План: [docs/roadmap.md](docs/roadmap.md) +- Документация: [docs/README.md](docs/README.md) - Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md) ## Структура @@ -67,4 +66,4 @@ pnpm generate && (cd services/api && go generate ./...) Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него. Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`, -[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md). +[docs/install-guide.md](docs/install-guide.md)). Нужные секреты перечислены в шапке `.gitea/workflows/release.yml`. diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index c226d62..cf3fef0 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -315,6 +315,15 @@ describe('my wave', () => { }); }); +describe('suggest tracks', () => { + it('warns that FLAC and WAV are the formats to send', async () => { + const admin = makeUser('admin', 'admin'); + await renderApp([admin], '/uploads/new', { signedInAs: admin }); + expect(await screen.findByText(/Нужны FLAC или WAV/)).toBeInTheDocument(); + expect(screen.getByText(/MP3 — только в крайнем случае/)).toBeInTheDocument(); + }); +}); + describe('accessibility', () => { it('home and login have no serious axe violations', async () => { const alice = makeUser('alice', 'user'); diff --git a/apps/web/src/features/uploads/files.test.ts b/apps/web/src/features/uploads/files.test.ts new file mode 100644 index 0000000..4a2e828 --- /dev/null +++ b/apps/web/src/features/uploads/files.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { needsFormatAttention } from './files'; + +describe('needsFormatAttention', () => { + it('marks everything except flac and wav', () => { + expect(needsFormatAttention('flac', 'a.mp3')).toBe(false); + expect(needsFormatAttention('WAV', 'track.wav')).toBe(false); + expect(needsFormatAttention(null, 'Кино/01.wav')).toBe(false); + expect(needsFormatAttention('mp3', 'a.flac')).toBe(true); + expect(needsFormatAttention(null, 'folder/song.MP3')).toBe(true); + expect(needsFormatAttention(null, 'song.m4a')).toBe(true); + expect(needsFormatAttention(null, 'noext')).toBe(false); + }); +}); diff --git a/apps/web/src/features/uploads/files.ts b/apps/web/src/features/uploads/files.ts index 911100d..7e89ceb 100644 --- a/apps/web/src/features/uploads/files.ts +++ b/apps/web/src/features/uploads/files.ts @@ -8,6 +8,22 @@ export interface PickedFile { /** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */ export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv']; +/** То, что просим присылать. MP3 и прочие — крайний случай, их помечает очередь. */ +const PREFERRED_AUDIO = new Set(['flac', 'wav']); + +export function audioExt(name: string): string { + const base = name.split(/[/\\]/).pop() ?? name; + const dot = base.lastIndexOf('.'); + if (dot < 0 || dot === base.length - 1) return ''; + return base.slice(dot + 1).toLowerCase(); +} + +/** Не FLAC и не WAV: в очереди на добавление такой файл нужно выделить. */ +export function needsFormatAttention(format: string | null | undefined, name: string): boolean { + const ext = (format || audioExt(name)).toLowerCase(); + return ext !== '' && !PREFERRED_AUDIO.has(ext); +} + export function isAudio(name: string): boolean { const ext = name.split('.').pop()?.toLowerCase() ?? ''; return AUDIO_EXTENSIONS.includes(ext); diff --git a/apps/web/src/pages/admin/UploadQueuePage.tsx b/apps/web/src/pages/admin/UploadQueuePage.tsx index 06fc69c..1b5208f 100644 --- a/apps/web/src/pages/admin/UploadQueuePage.tsx +++ b/apps/web/src/pages/admin/UploadQueuePage.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../../app/session'; +import { needsFormatAttention } from '../../features/uploads/files'; import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format'; import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons'; @@ -309,6 +310,11 @@ function QueueRow({
+ {needsFormatAttention(f.format, f.original_name) && ( + + {t('uploads.flag_attention')} + + )} {f.possible_duplicate && {t('uploads.flag_possible')}} {f.quality_upgrade && {t('uploads.flag_quality')}} {f.matched_album_id ? ( diff --git a/apps/web/src/pages/home.module.css b/apps/web/src/pages/home.module.css index 7eba488..f6de17e 100644 --- a/apps/web/src/pages/home.module.css +++ b/apps/web/src/pages/home.module.css @@ -10,6 +10,17 @@ user-select: none; /* главная — только элементы управления волной */ } +/* Плеер на всю ширину окна, а главная — в колонке справа от сайдбара. + Сдвиг на половину сайдбара ставит волну на ту же ось, что и кнопка play. + Если чипам не хватает места, сдвиг меньше, чтобы они не уехали под меню. */ +@media (min-width: 768px) { + .home { + transform: translateX( + calc(-1 * min(var(--side-w, 240px) / 2, max(0px, (100% - 760px) / 2))) + ); + } +} + .orbLabel { margin-top: 4px; font-family: var(--font-display); diff --git a/apps/web/src/pages/uploads/SuggestPage.tsx b/apps/web/src/pages/uploads/SuggestPage.tsx index b3d982c..af7d5ac 100644 --- a/apps/web/src/pages/uploads/SuggestPage.tsx +++ b/apps/web/src/pages/uploads/SuggestPage.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, useState, useSyncExternalStore, type DragEvent } fro import { useTranslation } from 'react-i18next'; import { useSession } from '../../app/session'; -import { AUDIO_EXTENSIONS, fromDrop, fromInput } from '../../features/uploads/files'; +import { AUDIO_EXTENSIONS, fromDrop, fromInput, needsFormatAttention } from '../../features/uploads/files'; import { uploaderFor, type UploadItem } from '../../features/uploads/uploader'; import { useErrorText } from '../../shared/lib/errors'; import { formatBytes } from '../../shared/lib/format'; @@ -81,7 +81,8 @@ export function SuggestPage() { >
{t('uploads.drop_title')}
-
{t('uploads.drop_hint', { formats: AUDIO_EXTENSIONS.join(', ') })}
+

{t('uploads.drop_hint')}

+
{t('uploads.drop_folders')}
)} + {needsFormatAttention(null, item.relPath) && {t('uploads.format_attention')}} {note && {note.text}}
{formatBytes(item.size, i18n.language)} diff --git a/apps/web/src/pages/uploads/uploads.module.css b/apps/web/src/pages/uploads/uploads.module.css index 373088f..cceef70 100644 --- a/apps/web/src/pages/uploads/uploads.module.css +++ b/apps/web/src/pages/uploads/uploads.module.css @@ -25,6 +25,15 @@ font-weight: 600; } +.formatWarn { + max-width: 640px; + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.45; + color: var(--warn); +} + .dropActions { display: flex; flex-wrap: wrap; diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css index 5154108..ba32f07 100644 --- a/apps/web/src/widgets/AppShell.module.css +++ b/apps/web/src/widgets/AppShell.module.css @@ -1,7 +1,8 @@ .shell { + --side-w: 240px; position: relative; display: grid; - grid-template-columns: 240px 1fr; + grid-template-columns: var(--side-w) 1fr; grid-template-rows: 60px 1fr 88px; grid-template-areas: 'top top' @@ -234,7 +235,7 @@ @media (max-width: 1023px) { .shell { - grid-template-columns: 72px 1fr; + --side-w: 72px; } .navItem { @@ -269,6 +270,7 @@ @media (max-width: 767px) { .shell { + --side-w: 0px; grid-template-columns: 1fr; grid-template-rows: 60px 1fr 72px 64px; grid-template-areas: diff --git a/apps/web/src/widgets/SearchBox.tsx b/apps/web/src/widgets/SearchBox.tsx index a9bfa9b..7d01e2f 100644 --- a/apps/web/src/widgets/SearchBox.tsx +++ b/apps/web/src/widgets/SearchBox.tsx @@ -29,7 +29,7 @@ type Item = { */ export function SearchBox({ align = 'center', - collapsible = true, + collapsible = false, }: { align?: 'left' | 'center' | 'right'; collapsible?: boolean; diff --git a/docs/IDEA.md b/docs/IDEA.md index d7d5790..5396595 100644 --- a/docs/IDEA.md +++ b/docs/IDEA.md @@ -1,7 +1,6 @@ # Pipu Music — исходная идея продукта -> Документ описывает **что** должно быть в приложении с точки зрения пользователя. -> Используется как входные данные для создания дизайна. Технические решения — в [README документации](README.md). +> Как продукт выглядит для пользователя. Устройство системы — в [README документации](README.md). ## Суть @@ -165,5 +164,3 @@ - Музыка хранится на сервере, подключение к хранилищу по NFS; хранилища подключает админ из админки. - Одобренные новые треки приложение само записывает на файловый сервер, соблюдая принятую там структуру папок. -- Один репозиторий: веб, мобильное приложение, бэкенд, тесты, CI/CD. -- CI/CD на Gitea: по тегу собирается релиз. diff --git a/docs/README.md b/docs/README.md index 68d61bb..27ed382 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,10 +6,9 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android | Документ | О чём | |---|---| -| [IDEA.md](IDEA.md) | Исходная идея продукта глазами пользователя (вход для дизайна) | -| [design.md](design.md) | Дизайн-система (токены, шрифты, анимация), кнопка волны, решения по вариантам прототипа, ссылки на макеты | +| [IDEA.md](IDEA.md) | Продукт глазами пользователя | +| [design.md](design.md) | Дизайн-система: токены, шрифты, анимация, кнопка волны, каркас экранов | | [architecture.md](architecture.md) | Общая архитектура, компоненты, потоки данных, структура репозитория | -| [decisions.md](decisions.md) | ADR — принятые архитектурные решения и их обоснование | | [data-model.md](data-model.md) | Схема БД PostgreSQL | | [api.md](api.md) | REST API: соглашения, эндпоинты, ошибки | | [auth-and-roles.md](auth-and-roles.md) | Аутентификация, токены, роли и права, первый запуск | @@ -20,17 +19,13 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android | [web.md](web.md) | Веб-клиент | | [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление | | [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) | -| [testing.md](testing.md) | Стратегия тестирования | -| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу | | [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android | | [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика | | [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные | -| [conventions.md](conventions.md) | Соглашения по коду, git, версиям | -| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности | ## Стек в одном абзаце Backend API — **Go**; рекомендации и аудиоанализ — **Python**; БД — **PostgreSQL + pgvector**; поиск — **Meilisearch**; кэш / очереди событий — **Redis**; раздача аудио — **nginx (X-Accel-Redirect)**; веб — **React + TypeScript + Vite**; мобильное — **React Native (Expo, Android)**; -монорепо — **pnpm + Turborepo**; CI/CD — **Gitea Actions**; деплой — **Docker Compose**. +деплой — **Docker Compose**. diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 2502ad3..4a5e5b9 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -114,7 +114,6 @@ Android-приложение обновляется само: при запус | Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`) | | Место на диске | `df -h /srv/pipu`; больше всего занимает `cache/transcode` (чистится сам) | | Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» | -| Нагрузка | `tests/load/smoke.js` — см. [testing.md](testing.md#нагрузочный-smoke) | ## 8. Частые ситуации diff --git a/docs/api.md b/docs/api.md index dc3aeb8..bc85e93 100644 --- a/docs/api.md +++ b/docs/api.md @@ -136,7 +136,7 @@ | GET | `/uploads/quota` | moderator, admin | `{limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes}` | | POST | `/uploads/precheck` | moderator, admin | `{files:[{client_id, size, sha256, tags:{artist,title,album,duration_ms}}]}` → для каждого `accepted` / `duplicate` (+ `track`) / `too_large`; ничего не резервирует | | POST | `/uploads` | moderator, admin | Создать пачку `{comment, files:[{client_id, name, rel_path, size, sha256}]}` → резерв квоты; `{batch_id, files:[{client_id, upload_file_id, tus_url} | {client_id, rejected: code}]}`; при нехватке места — `uploads.queue_full` для не поместившихся | -| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`, ядро tus 1.0.0 — ADR-015) | +| tus | `/uploads/files/{upload_file_id}` | автор | Докачка (`HEAD`/`PATCH`, ядро tus 1.0.0) | | GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения (`status` — через запятую) | | DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения | | GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам | diff --git a/docs/architecture.md b/docs/architecture.md index 6494b99..53205fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,7 +101,7 @@ pipu-music/ │ └── recommender/ # Python (uv) │ ├── pipu_rec/ │ │ ├── api/ # FastAPI -│ │ ├── analysis/ # Essentia +│ │ ├── analysis/ # признаки и эмбеддинги (numpy) │ │ ├── candidates/ ranking/ wave/ training/ │ │ └── storage/ # доступ к Postgres / Redis │ └── tests/ @@ -111,10 +111,6 @@ pipu-music/ │ ├── compose.dev.yaml │ ├── nginx/ │ └── .env.example -├── tests/ -│ ├── e2e/ # Playwright -│ └── fixtures/music/ # генерируемые тестовые аудиофайлы -├── .gitea/workflows/ # ci-*.yml, docker.yml (по областям), release.yml ├── docs/ ├── turbo.json pnpm-workspace.yaml package.json └── README.md @@ -127,7 +123,7 @@ pipu-music/ не лезут в чужие таблицы напрямую. - **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`. - CI падает, если сгенерированный код не совпадает с контрактом. + Сгенерированный код совпадает с контрактом. - **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети, описан отдельными `internal-*.yaml` спеками. - **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7), diff --git a/docs/ci-cd.md b/docs/ci-cd.md deleted file mode 100644 index b96c6ae..0000000 --- a/docs/ci-cd.md +++ /dev/null @@ -1,138 +0,0 @@ -# CI/CD (Gitea Actions) - -Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner` -с доступом к Docker. - -Сборке APK нужно ~15 ГБ свободного места на хосте раннера (Android SDK, NDK ~2.5 ГБ, кэш Gradle, -C++-сборка React Native). APK собирается только под `arm64-v8a`. Если job падает с `No space left on device`, -освободите место на хосте раннера: `docker system prune -af` и старые кэши act в `~/.cache/act`. - -## Версионирование - -- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их - предлагает только при включённой настройке «бета-версии»). -- Одна версия на весь продукт: образы, APK и веб собираются с одним номером. -- Android `versionCode = MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N)`, - так что `1.2.0-rc.1 < 1.2.0-rc.2 < 1.2.0 < 1.2.1`; MINOR и PATCH ≤ 99. Считает `scripts/version.mjs` - (тесты — `pnpm test:scripts`). -- Changelog — `git-cliff` из Conventional Commits. - -## CI — на каждый push в `main` и PR - -Проверки разбиты на workflow по областям, у каждого фильтр путей (`on.push.paths`): запускается только то, -что затронуто коммитом. Коммиты только в `docs/` и `*.md` CI не запускают вовсе. - -| Workflow | Job | Что делает | Когда | -|---|---|---|---| -| `ci-api.yml` | `api` | gofmt, go vet, `go test -race`, сборка | `services/api/**`, `api/openapi.yaml` | -| `ci-contract.yml` | `contract` | регенерация Go-сервера, TS-клиента и токенов; всё ли закоммичено | `openapi.yaml`, `gen/`, `packages/api-client`, `packages/design-tokens`, общие файлы JS | -| `ci-recommender.yml` | `recommender` | ruff, mypy, pytest | `services/recommender/**` | -| `ci-ts.yml` | `ts` | i18n, тесты скриптов, typecheck (вкл. mobile), vitest, сборка веба | `apps/**`, `packages/**`, `scripts/**`, общие файлы JS | -| `ci-mobile.yml` | `mobile` | `expo-doctor`, Metro-бандл Android | `apps/mobile/**`, `packages/**`, общие файлы JS | -| `docker.yml` | `docker` | сборка всех 4 образов без push | `Dockerfile`, `.dockerignore`, `deploy/**`, манифесты зависимостей | - -Общие файлы JS: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `tsconfig.base.json`. -Изменение самого workflow запускает его. У каждого workflow своя группа `concurrency`: новый push в ту же -ветку отменяет устаревший прогон. - -`docker` собирает образы обычным `docker build` через Docker хоста раннера, а не в buildx-контейнере: -кэш слоёв переживает job, и повторная сборка занимает секунды. Кэш растёт — на хосте раннера раз в сутки: -`docker builder prune -af --filter until=72h` (см. [настройку раннера](#настройка-раннера)). - -### Кэши - -| Что | Где кэшируется | Чем | -|---|---|---| -| pnpm store | все job с `pnpm install` | `setup-node` `cache: pnpm` (кэш actions раннера) | -| Go-модули и build cache | `api`, `contract` | `setup-go` | -| uv | `recommender` | `setup-uv` `enable-cache` | -| Результаты turbo (typecheck, test, build) | `ts` | `actions/cache` для `.turbo/cache` | -| `npx expo-doctor`, трансформации Metro | `mobile` | `actions/cache` для `~/.npm/_npx` и `/tmp/metro-cache` | -| Слои Docker и `--mount=type=cache` | `docker`, сборка на сервере | Docker хоста; манифесты копируются раньше кода | -| Gradle (дистрибутив, зависимости, build cache), Android SDK и NDK | `android` в `release.yml` | тома `/opt/ci-cache/*` | -| Node, Go, Java, скачанные setup-action'ами | все job | том `act-toolcache` раннера | - -Кэш actions работает, только если он включён на раннере ([настройка](#настройка-раннера)); без него в логах -`Cache action is only supported on GHES…`, и всё скачивается заново. - -## `release.yml` — по тегу - -```yaml -on: - push: - tags: ['v*.*.*'] - -jobs: - release: # версия из тега (scripts/version.mjs) и сразу Gitea Release (scripts/release.mjs create) - android: # needs: release — собирает APK и докладывает его в релиз -``` - -Тесты в релизе не повторяются: тег ставится на коммит, который CI уже проверил в `main`. **Ставьте тег -после зелёного CI** — релиз сам этого не проверяет. - -Релиз появляется в первую же минуту, не дожидаясь APK. Текст строит `scripts/release.mjs`: - -- **статус Android** вверху: «⏳ собирается» со ссылкой на ход сборки → «✅ собран» (имя файла, размер, - versionCode, SHA-256; предупреждение, если подпись отладочная) или «❌ не удалась» со ссылкой на лог; -- **изменения** — коммиты (Conventional Commits) с прошлого тега по разделам «Новое», «Исправления», - «Сборка и CI» и т. д., отдельно — несовместимые (`feat!:`); для финального релиза отсчёт от прошлого - финального, а не от последнего `-rc`; ссылка на полный diff; -- **обновление сервера** — `./update.sh vX.Y.Z`, проверка версии; **установка APK** и проверка SHA-256. - -Job `android`: Android SDK, `expo prebuild`, `./gradlew assembleRelease --build-cache` (только arm64-v8a), подпись `apksigner`, -затем `release.mjs android success ` прикладывает APK и `.sha256` и меняет статус. При любом сбое — -`release.mjs android failure`. Перезапуск job безопасен: статус снова «собирается», файлы заменяются. -Перезапуск всего workflow обновляет текст существующего релиза, а не создаёт второй. - -Docker-образы **не публикуются**: сервер собирает их сам из клонированного репозитория (ADR-013). -Чтобы сборка на сервере не ломалась, job `docker` в `ci.yml` собирает все образы без push на каждый PR. - -## Настройка раннера - -Раннер — `act_runner` на отдельной VM. Что ускоряет CI/CD (`config.yaml` раннера, затем перезапуск сервиса): - -```yaml -runner: - capacity: 2 # два job параллельно; только если у VM ≥ 8 ГБ RAM (иначе APK-сборка упирается в OOM) -cache: - enabled: true # кэш actions/cache: pnpm store, Go-модули, uv - host: 10.10.1.x # IP хоста раннера, доступный из job-контейнеров (не 127.0.0.1) - port: 8088 # фиксированный порт; открыть его для docker-сетей -container: - valid_volumes: - - /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml -``` - -Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=7g` без свопа, Gradle `-Xmx2g`, Metro `--max-old-space-size=1536`, таймаут 60 мин, CMake / ninja / Gradle -по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там: -фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста, -лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост. - -Без `valid_volumes` раннер пропускает тома из `release.yml`, и каждый релиз заново качает Gradle -и NDK (~4 мин). Признак выключенного кэша actions в логах — `Cache action is only supported on GHES…`. - -Обслуживание хоста раннера (cron, раз в сутки): - -``` -0 4 * * * docker image prune -af --filter "until=72h"; docker builder prune -af --filter "until=72h" -``` - -Кэши в `/opt/ci-cache` занимают ~5–6 ГБ (Gradle, SDK, NDK); если место кончается — их можно удалить целиком, -следующий релиз скачает всё заново. - -## Секреты - -| Секрет | Назначение | -|---|---| -| `RELEASE_TOKEN` | Необязательно. Создание и редактирование релиза, загрузка ассетов (scope `write:repository`). Без него используется встроенный токен job (`GITHUB_TOKEN`), у которого есть права на запись в этот репозиторий | -| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. | - -Переменная репозитория `PUBLIC_URL` (Settings → Actions → Variables) — внешний адрес Gitea для ссылок -в тексте релиза, по умолчанию `https://gitea.hsrv.site`. Нужна потому, что job обращается к API по внутреннему -адресу, и ссылки из ответа API ведут туда же. - -## Деплой - -Автодеплой не обязателен. На сервере в клоне репозитория: `deploy/update.sh [vX.Y.Z]` — checkout тега, -`docker compose build` и `up -d`; миграции применяются при старте `api` ([install-guide.md](install-guide.md)). -Опционально — job `deploy` в `release.yml` по SSH на сервер для не-pre-release тегов (с ручным approval через environment). diff --git a/docs/conventions.md b/docs/conventions.md deleted file mode 100644 index c191d47..0000000 --- a/docs/conventions.md +++ /dev/null @@ -1,42 +0,0 @@ -# Соглашения - -## Git - -- Trunk-based: короткие ветки от `main`, merge через PR (squash). -- Имена веток: `feat/wave-moods`, `fix/scan-rename`, `chore/ci-cache`. -- Conventional Commits: `feat(wave): add running profile`, `fix(api): ...`. Скоупы: `api`, `agent`, `rec`, `web`, - `mobile`, `core`, `i18n`, `ci`, `deploy`, `docs`. Breaking change — `!` и раздел `BREAKING CHANGE:`. -- Изменение API = изменение `openapi.yaml` + регенерация клиентов в том же PR. -- Существенные архитектурные решения — новый ADR в [decisions.md](decisions.md). - -## Go (`services/api`) - -- `gofumpt`, `golangci-lint` (конфиг в репо). Ошибки оборачиваются `fmt.Errorf("...: %w", err)`; - доменные ошибки — типизированные (`ErrForbidden`, `ErrNotFound`) и маппятся в problem+json в одном месте. -- Контекст первым аргументом, без глобального состояния, зависимости через конструкторы (`NewService(repo, clock, ...)`). -- `clock` и генератор ID — интерфейсы (детерминированные тесты). -- SQL — только в `queries/*.sql` (sqlc), без ORM. - -## Python (`services/recommender`) - -- `uv`, `ruff` (lint + format), `mypy --strict`, Pydantic v2 для схем. -- Тяжёлые вычисления — `numpy`, без циклов Python по трекам. -- Конфиги контекстов и весов — YAML, валидируются Pydantic при старте. - -## TypeScript - -- `strict`, ESLint (flat config) + Prettier, без `any` (кроме сгенерированного кода). -- Общая логика — в `packages/core`, платформенная — только адаптеры в `apps/*`. -- Все строки UI — через i18n, хардкод текста ловит `eslint-plugin-i18next`. - -## API - -- `snake_case` в JSON, курсорная пагинация, problem+json с `code`. -- Новые поля — только дополнительные; удаление / переименование — через новую версию эндпоинта или депрекацию на один минорный релиз. - -## Безопасность - -- Секреты только через env / docker secrets, никогда в репозитории. -- Любой ввод, попадающий в команды (`mount`, `ffmpeg`), — только как отдельные аргументы `exec.Command`, без shell, - с whitelist-валидацией. -- Пути файлов — проверка, что итоговый путь внутри корня источника (`filepath.Rel` без `..`). diff --git a/docs/decisions.md b/docs/decisions.md deleted file mode 100644 index e81ae6b..0000000 --- a/docs/decisions.md +++ /dev/null @@ -1,205 +0,0 @@ -# Архитектурные решения (ADR) - -Формат: контекст → решение → последствия. Новое решение — новый раздел с номером; старые не переписываются, -а помечаются `Заменено ADR-NNN`. - ---- - -## ADR-001. Монорепозиторий - -**Контекст.** Веб, мобильное, бэкенд, тесты и CI должны жить в одном репозитории; у веба и мобильного много общего -(API-клиент, логика очереди и событий, словари i18n). - -**Решение.** Монорепо: pnpm workspaces + Turborepo для TS-части; Go-модуль в `services/api`; Python-проект -(uv) в `services/recommender`. Единый `api/openapi.yaml`. - -**Последствия.** Атомарные изменения контракта и клиентов в одном PR. CI использует фильтры путей, -чтобы не гонять всё на каждый коммит. - ---- - -## ADR-002. Backend API на Go - -**Контекст.** Нагрузка — IO (стриминг, NFS, БД), десятки одновременных слушателей; важна простота деплоя и -низкое потребление памяти. - -**Решение.** Go 1.23+: роутер `chi`, `oapi-codegen` (strict server), `pgx` + `sqlc`, миграции `goose`, -фоновые задачи `River` (очередь в Postgres), логирование `log/slog`, конфиг через env (`caarlos0/env`). - -**Последствия.** Один статический бинарник на сервис, быстрые холодные старты, тайпсейф SQL. -ML-код в Go не пишем — для этого отдельный сервис (ADR-003). - ---- - -## ADR-003. Рекомендации и аудиоанализ — отдельный Python-сервис - -**Контекст.** Нужны: аудиоанализ (Essentia и её предобученные модели), коллаборативная фильтрация (implicit ALS), -learning-to-rank (LightGBM). Экосистема есть только в Python. - -**Решение.** `services/recommender`: FastAPI (онлайн-инференс), отдельный процесс `analyzer` (тот же образ), -обучение по расписанию (APScheduler внутри сервиса). Python 3.12, `uv`, `numpy`, `implicit`, `lightgbm`, -`essentia-tensorflow`, `psycopg`, `redis`. - -**Последствия.** Два языка на бэкенде. Граница чёткая: Python не обслуживает пользователей напрямую, -только внутренние вызовы `api`. При падении сервиса работает fallback в `api`. - -⚠️ Предобученные модели Essentia распространяются под CC BY-NC-SA 4.0 — допустимо для частного -некоммерческого self-hosted использования. При коммерческом использовании нужно заменить модели. - ---- - -## ADR-004. Мобильное приложение — React Native (Expo), только Android - -**Контекст.** Только Android, самообновление APK из релизов, фоновое воспроизведение, офлайн, анимированная кнопка волны. -Рассматривались Flutter и React Native. - -**Решение.** React Native + Expo SDK (prebuild / dev client, не Expo Go), `expo-router`, -`react-native-track-player` (ExoPlayer под капотом, MediaSession, уведомление, кэш), `@shopify/react-native-skia` -+ `react-native-reanimated` для волны, `expo-sqlite` для офлайн-данных, `expo-file-system` для загрузок. - -**Почему не Flutter.** Flutter не хуже по возможностям, но React Native даёт общий TypeScript-код с вебом: -API-клиент, типы, логику очереди / событий / офлайн-синхронизации, словари i18n, дизайн-токены. -Для команды из одного-двух человек это существенно сокращает объём работы. - -**Последствия.** iOS не поддерживается (самообновление в обход стора там невозможно); при необходимости -iOS добавляется позже через TestFlight без смены стека. - ---- - -## ADR-005. Раздача аудио через nginx X-Accel-Redirect - -**Контекст.** Нужна перемотка (HTTP Range), высокая пропускная способность, минимум нагрузки на бэкенд. - -**Решение.** Клиент обращается к `api`, `api` проверяет права и подпись ссылки и отвечает заголовком -`X-Accel-Redirect` на `internal` location nginx. Файл отдаёт nginx. - -**Последствия.** Go не держит долгих соединений со стримами. Тома библиотеки и кэша транскодов -должны быть смонтированы и в `api`, и в `nginx` по одинаковым путям. - ---- - -## ADR-006. Подписанные ссылки на стрим - -**Контекст.** `