feat: M0 monorepo skeleton with services, clients, deploy and CI
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 3m55s
ci / api (push) Successful in 51s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
release / images (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
release / images (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / recommender (push) Successful in 1m11s
ci / ts (packages + web) (push) Canceled after 3s
release / verify (push) Successful in 1m16s
release / images (apps/web/Dockerfile, web, ) (push) Failing after 2m40s
release / android + release (push) Skipped
release / images (services/api/Dockerfile, api, api) (push) Canceled after 1s

- api/openapi.yaml: contract with /healthz, /readyz, /api/v1/server-info
- services/api (Go): api with oapi-codegen strict server, goose migrations,
  readiness checks for Postgres/Redis; storage-agent stub with internal token
- services/recommender (Python, uv): FastAPI health/readiness/internal status,
  `pipu-rec serve` and `pipu-rec analyze` entrypoints
- packages: generated api-client, core (server URL + server check),
  i18n (ru/en + dictionary check), design-tokens (CSS vars + TS from tokens.json)
- apps/web: Vite + React shell showing server status, language switch
- apps/mobile: Expo (SDK 53) Android app with server connect screen
- deploy: Dockerfiles, compose (prod + dev), nginx template, install/update scripts
- .gitea/workflows: ci.yml and tag-based release.yml (images, signed APK, release)
- scripts/version.mjs: version and Android versionCode from git tag
- tests/fixtures/music/generate.sh: synthetic test library via ffmpeg
- docs: versionCode formula, OpenAPI 3.0.3, dev instructions in README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-19 23:55:11 +03:00
1 parent 9eb1c25147
commit ebf296542c
98 files changed
+12130 -4

No files matched your search

+6
View File
@@ -6,6 +6,12 @@
"runtimeExecutable": "python", "runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1", "--directory", "docs"], "runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1", "--directory", "docs"],
"port": 8765 "port": 8765
},
{
"name": "web",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@pipu/web", "dev"],
"port": 5173
} }
] ]
} }
+16
View File
@@ -0,0 +1,16 @@
**/node_modules
**/dist
**/.turbo
**/.venv
**/__pycache__
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
.git
.claude
docs
tests/fixtures/music/out
apps/mobile/android
apps/mobile/.expo
**/*.apk
.env
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.go]
indent_style = tab
[*.py]
indent_size = 4
[Makefile]
indent_style = tab
+6
View File
@@ -0,0 +1,6 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.webp binary
*.apk binary
.thumbnail binary
+87
View File
@@ -0,0 +1,87 @@
# Проверки на каждый push в main и на каждый PR (docs/ci-cd.md).
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
GO_VERSION: "1.24"
NODE_VERSION: "22"
jobs:
contract:
name: contract (сгенерированный код совпадает с openapi.yaml)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
run: |
(cd services/api && go generate ./...)
pnpm generate
- name: Проверка, что всё закоммичено
run: git diff --exit-code -- services/api/internal/api/gen packages
api:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/api } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go test -race -coverprofile=coverage.out ./...
- run: go build ./cmd/...
recommender:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/recommender } }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy pipu_rec
- run: uv run pytest -q
ts:
name: ts (packages + web)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm i18n:check
- run: pnpm test:scripts
- run: pnpm typecheck
- run: pnpm test
- run: pnpm --filter @pipu/web build
mobile:
name: mobile (Metro-бандл Android)
runs-on: ubuntu-latest
defaults: { run: { working-directory: apps/mobile } }
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: npx expo-doctor
- run: npx expo export --platform android --output-dir dist
+166
View File
@@ -0,0 +1,166 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md):
# проверки → Docker-образы в Gitea Container Registry → подписанный APK → Gitea Release с ассетами.
#
# Переменные репозитория (Settings → Actions → Variables):
# REGISTRY — хост реестра, например git.home.lan (по умолчанию — хост сервера Gitea)
# Секреты (Settings → Actions → Secrets):
# REGISTRY_USER, REGISTRY_TOKEN — push образов (scope: write:package)
# RELEASE_TOKEN — создание релиза и загрузка ассетов (scope: write:repository)
# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD — подпись APK
name: release
on:
push:
tags: ["v*.*.*"]
env:
GO_VERSION: "1.24"
NODE_VERSION: "22"
jobs:
verify:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.PIPU_VERSION }}
version_code: ${{ steps.v.outputs.PIPU_VERSION_CODE }}
prerelease: ${{ steps.v.outputs.PIPU_PRERELEASE }}
steps:
- uses: actions/checkout@v4
- id: v
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2" }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Go
working-directory: services/api
run: go vet ./... && go test ./...
- name: Python
working-directory: services/recommender
run: uv sync --frozen && uv run ruff check . && uv run mypy pipu_rec && uv run pytest -q
- name: TypeScript
run: pnpm i18n:check && pnpm test:scripts && pnpm typecheck && pnpm test
images:
needs: verify
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
include:
- { name: api, dockerfile: services/api/Dockerfile, target: api }
- { name: storage-agent, dockerfile: services/api/Dockerfile, target: storage-agent }
- { name: recommender, dockerfile: services/recommender/Dockerfile, target: "" }
- { name: web, dockerfile: apps/web/Dockerfile, target: "" }
steps:
- uses: actions/checkout@v4
- name: Имя образа
id: img
run: |
registry="${{ vars.REGISTRY }}"
[ -n "$registry" ] || registry="$(echo '${{ github.server_url }}' | sed -E 's#^https?://##')"
owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
echo "registry=$registry" >> "$GITHUB_OUTPUT"
echo "image=$registry/$owner/pipu-music-${{ matrix.name }}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ steps.img.outputs.registry }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
target: ${{ matrix.target }}
push: true
build-args: VERSION=${{ needs.verify.outputs.version }}
tags: |
${{ steps.img.outputs.image }}:${{ needs.verify.outputs.version }}
${{ needs.verify.outputs.prerelease == 'false' && format('{0}:latest', steps.img.outputs.image) || '' }}
android-release:
name: android + release
needs: [verify, images]
runs-on: ubuntu-latest
env:
PIPU_VERSION: ${{ needs.verify.outputs.version }}
PIPU_VERSION_CODE: ${{ needs.verify.outputs.version_code }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "17" }
- uses: android-actions/setup-android@v3
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Сборка APK
working-directory: apps/mobile
run: |
npx expo prebuild --platform android --clean --no-install
cd android && ./gradlew --no-daemon assembleRelease
- name: Подпись APK
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
set -euo pipefail
src=apps/mobile/android/app/build/outputs/apk/release/app-release.apk
out="pipu-music-${PIPU_VERSION}.apk"
bt="$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)"
if [ -n "${KEYSTORE_B64}" ]; then
echo "$KEYSTORE_B64" | base64 -d > release.keystore
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
rm release.keystore
else
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
cp "$src" "$out"
fi
"$bt/apksigner" verify --print-certs "$out" | head -3
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
echo "APK=$out" >> "$GITHUB_ENV"
- name: Changelog
run: |
prev="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
range="${prev:+$prev..}${GITHUB_REF_NAME}"
{
echo "## Pipu Music ${PIPU_VERSION}"
echo
for t in feat fix perf refactor docs ci chore; do
lines="$(git log --no-merges --format='%s' "$range" | grep -E "^${t}(\(|:|!)" || true)"
[ -n "$lines" ] || continue
case $t in feat) h="Новое";; fix) h="Исправления";; perf) h="Производительность";; *) h="$t";; esac
echo "### $h"; echo "$lines" | sed 's/^/- /'; echo
done
} > CHANGELOG_RELEASE.md
cat CHANGELOG_RELEASE.md
- name: Gitea Release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
run: |
set -euo pipefail
body="$(jq -n --arg tag "$GITHUB_REF_NAME" --arg name "Pipu Music $PIPU_VERSION" \
--rawfile notes CHANGELOG_RELEASE.md --argjson pre "$PRERELEASE" \
'{tag_name:$tag, name:$name, body:$notes, prerelease:$pre, draft:false}')"
id="$(curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "$body" "$API/releases" | jq -r .id)"
for f in "$APK" "$APK.sha256" deploy/compose.yaml deploy/.env.example deploy/install.sh deploy/update.sh; do
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" > /dev/null
echo "uploaded $(basename "$f")"
done
+36
View File
@@ -0,0 +1,36 @@
# deps / builds
node_modules/
dist/
build/
coverage/
.turbo/
*.tsbuildinfo
.expo/
apps/mobile/android/
apps/mobile/ios/
# go
services/api/bin/
# python
__pycache__/
.venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# env / secrets
.env
*.pem
*.keystore
*.jks
# generated test audio
tests/fixtures/music/out/
# os / editors
.DS_Store
Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json
+2
View File
@@ -0,0 +1,2 @@
# React Native / Expo в pnpm-монорепо требует плоского node_modules
node-linker=hoisted
+60
View File
@@ -6,3 +6,63 @@ Self-hosted музыкальный сервис: веб и Android-прилож
- Идея продукта: [docs/IDEA.md](docs/IDEA.md) - Идея продукта: [docs/IDEA.md](docs/IDEA.md)
- Документация для разработки: [docs/README.md](docs/README.md) - Документация для разработки: [docs/README.md](docs/README.md)
- План: [docs/roadmap.md](docs/roadmap.md) - План: [docs/roadmap.md](docs/roadmap.md)
## Структура
| Путь | Что |
|---|---|
| `api/openapi.yaml` | Контракт публичного API — источник истины для сервера и клиентов |
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
| `apps/web` | Веб-клиент (React + Vite) |
| `apps/mobile` | Android-приложение (React Native + Expo) |
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
| `tests/fixtures/music` | Генератор тестовой библиотеки (нужен ffmpeg) |
| `.gitea/workflows` | CI (`ci.yml`) и релиз по тегу (`release.yml`) |
## Разработка
Нужны: Go 1.24+, Node 22 + pnpm 11, uv, Docker; для Android — JDK 17 и Android SDK.
```bash
pnpm install
docker compose -f deploy/compose.dev.yaml up -d
```
API (из `services/api`):
```bash
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" go run ./cmd/api
```
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
```bash
pnpm --filter @pipu/web dev
```
Проверки — те же, что в CI:
```bash
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
```
```bash
cd services/api && go vet ./... && go test ./...
```
```bash
cd services/recommender && uv run ruff check . && uv run mypy pipu_rec && uv run pytest
```
После изменения `api/openapi.yaml` или `packages/design-tokens/tokens.json` перегенерируйте код и закоммитьте результат:
```bash
pnpm generate && (cd services/api && go generate ./...)
```
## Релиз
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: проверки → образы в Gitea Container Registry →
подписанный APK → Gitea Release. Нужные переменные и секреты перечислены в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md).
+129
View File
@@ -0,0 +1,129 @@
openapi: 3.0.3
info:
title: Pipu Music API
version: 0.1.0
description: |
Публичный REST API Pipu Music. Источник истины для сервера (oapi-codegen)
и клиентов (openapi-typescript). Соглашения — docs/api.md.
servers:
- url: /
tags:
- name: system
paths:
/healthz:
get:
operationId: getHealthz
tags: [system]
summary: Живость процесса
responses:
"200":
description: Процесс жив
content:
application/json:
schema:
$ref: "#/components/schemas/HealthStatus"
/readyz:
get:
operationId: getReadyz
tags: [system]
summary: Готовность обслуживать запросы (зависимости доступны)
responses:
"200":
description: Все зависимости доступны
content:
application/json:
schema:
$ref: "#/components/schemas/ReadyStatus"
"503":
description: Одна или несколько зависимостей недоступны
content:
application/json:
schema:
$ref: "#/components/schemas/ReadyStatus"
/api/v1/server-info:
get:
operationId: getServerInfo
tags: [system]
summary: Сведения о сервере для клиентов
description: |
Вызывается без аутентификации: веб и Android проверяют версию сервера,
совместимость клиента и поддерживаемые языки (экран подключения).
responses:
"200":
description: Сведения о сервере
content:
application/json:
schema:
$ref: "#/components/schemas/ServerInfo"
default:
$ref: "#/components/responses/Problem"
components:
responses:
Problem:
description: Ошибка (RFC 9457)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
schemas:
HealthStatus:
type: object
required: [status]
properties:
status:
type: string
enum: [ok]
ReadyStatus:
type: object
required: [status, checks]
properties:
status:
type: string
enum: [ok, degraded]
checks:
type: object
description: Результат проверки каждой зависимости
additionalProperties:
type: string
description: "`ok` или текст ошибки"
ServerInfo:
type: object
required: [name, version, api_version, supported_locales, min_android_version_code]
properties:
name:
type: string
example: pipu-music
version:
type: string
description: Версия сервера (SemVer)
example: 0.1.0
api_version:
type: string
example: v1
supported_locales:
type: array
items:
type: string
example: [ru, en]
min_android_version_code:
type: integer
description: Минимальный versionCode Android-приложения, которое поддерживает сервер
example: 10099
Problem:
type: object
required: [status, code]
properties:
type:
type: string
default: about:blank
status:
type: integer
code:
type: string
description: Машинный код ошибки для i18n, например `auth.invalid_credentials`
detail:
type: string
description: Пояснение для логов (не для показа пользователю)
params:
type: object
additionalProperties: true
+26
View File
@@ -0,0 +1,26 @@
import type { ConfigContext, ExpoConfig } from 'expo/config';
// Версию проставляет CI из git-тега (scripts/version.mjs): PIPU_VERSION=1.2.3, PIPU_VERSION_CODE=10203.
const version = process.env.PIPU_VERSION ?? '0.0.0';
const versionCode = Number(process.env.PIPU_VERSION_CODE ?? '1');
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'Pipu Music',
slug: 'pipu-music',
scheme: 'pipu',
version,
orientation: 'portrait',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
backgroundColor: '#05070D',
android: {
package: 'app.pipu.music',
versionCode,
edgeToEdgeEnabled: true,
allowBackup: false,
permissions: ['REQUEST_INSTALL_PACKAGES'],
},
platforms: ['android'],
extra: { versionCode },
});
+5
View File
@@ -0,0 +1,5 @@
import { registerRootComponent } from 'expo';
import { App } from './src/App';
registerRootComponent(App);
+4
View File
@@ -0,0 +1,4 @@
// Expo SDK 52+ сам настраивает Metro для монорепо (watchFolders, nodeModulesPaths).
const { getDefaultConfig } = require('expo/metro-config');
module.exports = getDefaultConfig(__dirname);
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@pipu/mobile",
"version": "0.0.0",
"private": true,
"main": "index.ts",
"scripts": {
"start": "expo start --dev-client",
"android": "expo run:android",
"prebuild": "expo prebuild --platform android --clean",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@pipu/api-client": "workspace:*",
"@pipu/core": "workspace:*",
"@pipu/design-tokens": "workspace:*",
"@pipu/i18n": "workspace:*",
"expo": "~53.0.20",
"expo-constants": "~17.1.7",
"expo-status-bar": "~2.2.3",
"i18next": "^25.2.1",
"react": "19.0.0",
"react-i18next": "^15.5.3",
"react-native": "0.79.6"
},
"devDependencies": {
"@types/react": "~19.0.10"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { createI18n, pickLocale } from '@pipu/i18n';
import { StatusBar } from 'expo-status-bar';
import type { i18n as I18n } from 'i18next';
import { useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import { ActivityIndicator, View } from 'react-native';
import { ConnectScreen } from './ConnectScreen';
import { colors } from './theme';
export function App() {
const [i18n, setI18n] = useState<I18n | null>(null);
useEffect(() => {
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
void createI18n(pickLocale([deviceLocale])).then(setI18n);
}, []);
if (!i18n) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
return (
<I18nextProvider i18n={i18n}>
<StatusBar style="light" />
<ConnectScreen />
</I18nextProvider>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { createApiClient } from '@pipu/api-client';
import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core';
import Constants from 'expo-constants';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors, radius } from './theme';
const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0);
type State = { kind: 'idle' } | { kind: 'checking' } | { kind: 'invalid' } | ({ kind: 'done' } & ServerCheck);
/** Экран подключения к серверу (docs/mobile.md). В M0 — только проверка /server-info. */
export function ConnectScreen() {
const { t } = useTranslation();
const [address, setAddress] = useState('');
const [state, setState] = useState<State>({ kind: 'idle' });
async function onCheck() {
const baseUrl = normalizeServerUrl(address);
if (!baseUrl) {
setState({ kind: 'invalid' });
return;
}
setState({ kind: 'checking' });
const res = await checkServer(createApiClient({ baseUrl }), appVersionCode);
setState({ kind: 'done', ...res });
}
return (
<View style={styles.screen}>
<View style={styles.card}>
<Text style={styles.title}>{t('app.name')}</Text>
<Text style={styles.muted}>{t('app.tagline')}</Text>
<Text style={styles.label}>{t('server.address')}</Text>
<TextInput
value={address}
onChangeText={setAddress}
onSubmitEditing={onCheck}
placeholder="https://music.home.lan"
placeholderTextColor={colors.text3}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={styles.input}
/>
{state.kind === 'checking' && (
<View style={[styles.status, { backgroundColor: colors.surface2 }]}>
<ActivityIndicator color={colors.text2} />
<Text style={{ color: colors.text2 }}>{t('server.checking')}</Text>
</View>
)}
{state.kind === 'invalid' && <Status ok={false} text={t('server.invalid_address')} />}
{state.kind === 'done' && state.status === 'ok' && (
<Status ok text={t('server.online', { version: state.info.version })} />
)}
{state.kind === 'done' && state.status === 'unsupported_app' && (
<Status ok={false} text={t('server.unsupported_app')} />
)}
{state.kind === 'done' && state.status === 'offline' && <Status ok={false} text={t('server.offline')} />}
<Pressable onPress={onCheck} style={({ pressed }) => [styles.button, pressed && { opacity: 0.85 }]}>
<Text style={styles.buttonText}>{t('server.check')}</Text>
</Pressable>
</View>
</View>
);
}
function Status({ ok, text }: { ok: boolean; text: string }) {
return (
<View style={[styles.status, { backgroundColor: ok ? colors['ok-soft'] : colors['danger-soft'] }]}>
<Text style={{ color: ok ? colors.ok : colors.danger, fontWeight: '500' }}>{text}</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: 16 },
card: {
gap: 12,
padding: 24,
borderRadius: radius.xl,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.line2,
},
title: { color: colors.text, fontSize: 26, fontWeight: '600' },
muted: { color: colors.text2, lineHeight: 21 },
label: { color: colors.text2, fontSize: 13, fontWeight: '500', marginTop: 8 },
input: {
height: 48,
paddingHorizontal: 14,
borderRadius: radius.md,
backgroundColor: colors.surface2,
borderWidth: 1,
borderColor: colors.line2,
color: colors.text,
fontSize: 15,
},
status: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12, borderRadius: 10 },
button: {
height: 48,
borderRadius: radius.md,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
buttonText: { color: '#fff', fontWeight: '600', fontSize: 15 },
});
+5
View File
@@ -0,0 +1,5 @@
import { tokens } from '@pipu/design-tokens';
// Пока только тёмная тема; переключение тем — вместе с профилем (M5).
export const colors = tokens.color.dark;
export const radius = tokens.radius;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["index.ts", "app.config.ts", "src"]
}
+20
View File
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
# Образ `web` = nginx + собранная SPA + конфиг (TLS, прокси на api, X-Accel-раздача аудио).
# Сборка из корня репозитория: docker build -f apps/web/Dockerfile --build-arg VERSION=1.2.3 .
FROM node:22-alpine AS build
RUN npm install -g pnpm@11.9.0
WORKDIR /repo
COPY . .
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter @pipu/web...
# Сгенерированные клиенты и токены лежат в репозитории (их актуальность проверяет CI), поэтому generate здесь не нужен.
RUN pnpm --filter @pipu/web build
FROM nginx:1.28-alpine
COPY deploy/nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /repo/apps/web/dist /usr/share/nginx/html
ARG VERSION=0.0.0-dev
LABEL org.opencontainers.image.version=${VERSION}
ENV API_UPSTREAM=api:8080
EXPOSE 80
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>pipu</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@pipu/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@fontsource/jetbrains-mono": "^5.2.6",
"@fontsource/onest": "^5.2.6",
"@fontsource/unbounded": "^5.2.6",
"@pipu/api-client": "workspace:*",
"@pipu/core": "workspace:*",
"@pipu/design-tokens": "workspace:*",
"@pipu/i18n": "workspace:*",
"@tanstack/react-query": "^5.80.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.3"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"jsdom": "^26.1.0",
"vite": "^6.3.5",
"vitest": "^3.2.4"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="g" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#9CC6FF"/><stop offset=".55" stop-color="#2F80FF"/><stop offset="1" stop-color="#3A1FA8"/></radialGradient></defs><circle cx="16" cy="16" r="14" fill="url(#g)"/></svg>

After

Width:  |  Height:  |  Size: 312 B

+51
View File
@@ -0,0 +1,51 @@
import { createApiClient } from '@pipu/api-client';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest';
import { App } from './App';
afterEach(cleanup);
const info = {
name: 'pipu-music',
version: '0.1.0',
api_version: 'v1',
supported_locales: ['ru', 'en'],
min_android_version_code: 100,
};
async function renderApp(fetchImpl: typeof fetch) {
const i18n = await createI18n('ru');
const client = createApiClient({ baseUrl: 'http://localhost', fetch: fetchImpl });
render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<App client={client} />
</QueryClientProvider>
</I18nextProvider>,
);
}
describe('App', () => {
it('shows the server version when the server is online', async () => {
await renderApp(async () => Response.json(info));
expect(await screen.findByText('Сервер 0.1.0 на связи')).toBeTruthy();
});
it('shows an error when the server is unreachable', async () => {
await renderApp(async () => {
throw new TypeError('Failed to fetch');
});
expect(await screen.findByText(/Сервер не отвечает/)).toBeTruthy();
});
it('switches the interface language', async () => {
await renderApp(async () => Response.json(info));
await screen.findByText('Сервер 0.1.0 на связи');
fireEvent.click(screen.getByRole('button', { name: 'EN' }));
expect(await screen.findByText('Server 0.1.0 is online')).toBeTruthy();
});
});
+57
View File
@@ -0,0 +1,57 @@
import { createApiClient, type ApiClient } from '@pipu/api-client';
import { checkServer } from '@pipu/core';
import { SUPPORTED_LOCALES } from '@pipu/i18n';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
const defaultClient = createApiClient();
export function App({ client = defaultClient }: { client?: ApiClient }) {
const { t, i18n } = useTranslation();
const server = useQuery({ queryKey: ['server-info'], queryFn: () => checkServer(client) });
return (
<div className="shell">
<header className="top">
<div className="logo">
<span className="logo-dot" aria-hidden />
{t('app.name')}
</div>
<div className="langs" role="group" aria-label={t('language.label')}>
{SUPPORTED_LOCALES.map((l) => (
<button
key={l}
type="button"
aria-pressed={i18n.language === l}
onClick={() => {
void i18n.changeLanguage(l);
document.documentElement.lang = l;
}}
>
{l.toUpperCase()}
</button>
))}
</div>
</header>
<main className="main">
<section className="card">
<h1>{t('app.name')}</h1>
<p className="muted">{t('app.tagline')}</p>
{server.data?.status === 'ok' ? (
<div className="status" data-state="ok" role="status">
{t('server.online', { version: server.data.info.version })}
</div>
) : server.data ? (
<div className="status" data-state="error" role="status">
{t('server.offline')}
</div>
) : (
<div className="status" data-state="pending" role="status">
{t('server.checking')}
</div>
)}
</section>
</main>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import './styles.css';
import { createI18n, pickLocale } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { I18nextProvider } from 'react-i18next';
import { App } from './App';
const LOCALE_KEY = 'pipu.locale';
function storedLocale(): string[] {
try {
const v = localStorage.getItem(LOCALE_KEY);
return v ? [v] : [];
} catch {
return [];
}
}
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
document.documentElement.lang = i18n.language;
i18n.on('languageChanged', (l) => {
try {
localStorage.setItem(LOCALE_KEY, l);
} catch {
// недоступное хранилище не мешает работе
}
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
createRoot(document.getElementById('root')!).render(
<StrictMode>
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</I18nextProvider>
</StrictMode>,
);
+151
View File
@@ -0,0 +1,151 @@
@import '@pipu/design-tokens/tokens.css';
@import '@fontsource/onest/400.css';
@import '@fontsource/onest/600.css';
@import '@fontsource/unbounded/600.css';
@import '@fontsource/jetbrains-mono/400.css';
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 15px;
-webkit-font-smoothing: antialiased;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
.shell {
min-height: 100%;
display: flex;
flex-direction: column;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 20px;
border-bottom: 1px solid var(--line);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 600;
font-size: 19px;
letter-spacing: -0.02em;
}
.logo-dot {
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
}
.langs {
display: flex;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: var(--glass);
border: 1px solid var(--line);
}
.langs button {
height: 30px;
padding: 0 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.langs button[aria-pressed='true'] {
background: var(--accent-soft);
color: var(--text);
}
.main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.card {
width: 400px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
padding: 36px;
border-radius: var(--radius-xl);
background: var(--glass);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
}
.card h1 {
margin: 0;
font-family: var(--font-display);
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
}
.muted {
margin: 0;
color: var(--text2);
line-height: 1.5;
}
.status {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
}
.status[data-state='ok'] {
background: var(--ok-soft);
color: var(--ok);
}
.status[data-state='error'] {
background: var(--danger-soft);
color: var(--danger);
}
.status[data-state='pending'] {
background: var(--surface2);
color: var(--text2);
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
/// <reference types="vitest/config" />
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
build: {
target: 'es2022',
},
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
// В разработке API поднимается docker compose (deploy/compose.dev.yaml) на :8080
proxy: {
'/api': 'http://localhost:8080',
'/healthz': 'http://localhost:8080',
},
},
test: {
environment: 'jsdom',
},
});
+25
View File
@@ -0,0 +1,25 @@
# Скопируйте в .env (install.sh делает это сам и генерирует секреты). Описание — docs/deployment.md.
# --- Образы ---
REGISTRY=git.example.lan/owner
PIPU_VERSION=0.1.0
PIPU_DATA=/srv/pipu
HTTP_PORT=80
# --- Секреты (install.sh заполнит случайными значениями) ---
POSTGRES_PASSWORD=
MEILI_MASTER_KEY=
INTERNAL_TOKEN=
# --- api / recommender ---
# DATABASE_URL собирается в compose.yaml из POSTGRES_PASSWORD
REDIS_URL=redis://redis:6379/0
MEILI_URL=http://meilisearch:7700
LOG_LEVEL=info
SUPPORTED_LOCALES=ru,en
MIN_ANDROID_VERSION_CODE=0
# --- Самообновление Android (M5) ---
GITEA_URL=https://git.example.lan
GITEA_REPO=owner/pipu-music
GITEA_TOKEN=
+69
View File
@@ -0,0 +1,69 @@
# Зависимости для локальной разработки.
# docker compose -f deploy/compose.dev.yaml up -d # postgres, redis, meilisearch
# docker compose -f deploy/compose.dev.yaml --profile app up # + api и recommender из исходников
# Затем: веб — `pnpm --filter @pipu/web dev` (проксирует /api на :8080),
# api без docker — см. README, раздел «Разработка».
name: pipu-dev
services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_USER: pipu
POSTGRES_PASSWORD: pipu
POSTGRES_DB: pipu
ports: ["5432:5432"]
volumes: [pg-data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pipu -d pipu"]
interval: 5s
retries: 10
redis:
image: redis:7-alpine
ports: ["6379:6379"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
meilisearch:
image: getmeili/meilisearch:v1.11
environment:
MEILI_MASTER_KEY: dev-master-key-change-me
MEILI_ENV: development
ports: ["7700:7700"]
volumes: [meili-data:/meili_data]
api:
profiles: [app]
build:
context: ..
dockerfile: services/api/Dockerfile
target: api
environment:
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
REDIS_URL: redis://redis:6379/0
LOG_LEVEL: debug
ports: ["8080:8080"]
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
recommender:
profiles: [app]
build:
context: ..
dockerfile: services/recommender/Dockerfile
environment:
DATABASE_URL: postgresql://pipu:pipu@postgres:5432/pipu
REDIS_URL: redis://redis:6379/0
INTERNAL_TOKEN: dev-internal-token
ports: ["8000:8000"]
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
volumes:
pg-data:
meili-data:
+104
View File
@@ -0,0 +1,104 @@
# Продакшен-развёртывание (docs/deployment.md). Перед первым запуском: sudo ./install.sh
# Обновление: ./update.sh <version>
name: pipu
x-library-ro: &library-ro
type: bind
source: ${PIPU_DATA:-/srv/pipu}/library
target: /library
read_only: true
bind: { propagation: rslave }
x-restart: &restart
restart: unless-stopped
services:
nginx:
<<: *restart
image: ${REGISTRY}/pipu-music-web:${PIPU_VERSION}
ports: ["${HTTP_PORT:-80}:80"]
volumes:
- *library-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache:ro
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
depends_on: [api]
api:
<<: *restart
image: ${REGISTRY}/pipu-music-api:${PIPU_VERSION}
env_file: .env
environment: &db-env
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD}@postgres:5432/pipu?sslmode=disable
volumes:
- *library-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
storage-agent: { condition: service_started }
storage-agent:
<<: *restart
image: ${REGISTRY}/pipu-music-storage-agent:${PIPU_VERSION}
cap_add: [SYS_ADMIN]
security_opt: ["apparmor:unconfined"]
environment:
INTERNAL_TOKEN: ${INTERNAL_TOKEN}
LIBRARY_ROOT: /library
volumes:
- type: bind
source: ${PIPU_DATA:-/srv/pipu}/library
target: /library
bind: { propagation: rshared }
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
recommender:
<<: *restart
image: ${REGISTRY}/pipu-music-recommender:${PIPU_VERSION}
command: ["serve"]
env_file: .env
environment: *db-env
depends_on:
postgres: { condition: service_healthy }
analyzer:
<<: *restart
image: ${REGISTRY}/pipu-music-recommender:${PIPU_VERSION}
command: ["analyze", "--workers", "4"]
env_file: .env
environment: *db-env
volumes:
- *library-ro
- ${PIPU_DATA:-/srv/pipu}/models:/models
deploy: { resources: { limits: { cpus: "4" } } }
depends_on:
postgres: { condition: service_healthy }
postgres:
<<: *restart
image: pgvector/pgvector:pg17
environment:
POSTGRES_USER: pipu
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: pipu
volumes: [ "${PIPU_DATA:-/srv/pipu}/postgres:/var/lib/postgresql/data" ]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pipu -d pipu"]
interval: 5s
retries: 20
redis:
<<: *restart
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes: [ "${PIPU_DATA:-/srv/pipu}/redis:/data" ]
meilisearch:
<<: *restart
image: getmeili/meilisearch:v1.11
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
MEILI_ENV: production
volumes: [ "${PIPU_DATA:-/srv/pipu}/meili:/meili_data" ]
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Первичная подготовка сервера: каталоги данных, общий корень библиотеки с mount propagation, .env с секретами.
# Запуск: sudo ./install.sh (повторный запуск безопасен)
set -euo pipefail
cd "$(dirname "$0")"
DATA="${PIPU_DATA:-/srv/pipu}"
echo "→ каталоги в $DATA"
mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,meili,redis,models,updates,uploads,backups}
# storage-agent монтирует NFS внутрь $DATA/library; чтобы точки монтирования видели другие контейнеры,
# каталог должен быть отдельной точкой монтирования с shared propagation.
if ! findmnt -n "$DATA/library" >/dev/null 2>&1; then
echo "→ bind-mount $DATA/library (rshared)"
mount --bind "$DATA/library" "$DATA/library"
fi
mount --make-rshared "$DATA/library"
UNIT=/etc/systemd/system/pipu-library-mount.service
if [ -d /etc/systemd/system ] && [ ! -f "$UNIT" ]; then
echo "→ systemd-юнит $UNIT (повторяет bind-mount после перезагрузки)"
cat > "$UNIT" <<EOF
[Unit]
Description=Pipu Music library mount propagation
Before=docker.service
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'findmnt -n $DATA/library || mount --bind $DATA/library $DATA/library; mount --make-rshared $DATA/library'
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable pipu-library-mount.service
fi
secret() { head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 40; }
if [ ! -f .env ]; then
echo "→ .env из .env.example"
cp .env.example .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|" .env
chmod 600 .env
echo " Проверьте REGISTRY и PIPU_VERSION в .env"
fi
echo "Готово. Запуск: ./update.sh \$(grep ^PIPU_VERSION= .env | cut -d= -f2)"
+48
View File
@@ -0,0 +1,48 @@
# Шаблон обрабатывается envsubst при старте контейнера nginx (переменные: API_UPSTREAM).
# TLS терминируется здесь или на внешнем прокси — см. docs/deployment.md.
upstream pipu_api {
server ${API_UPSTREAM};
keepalive 16;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
client_max_body_size 16m;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location /api/ {
proxy_pass http://pipu_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /healthz { proxy_pass http://pipu_api; }
location = /readyz { proxy_pass http://pipu_api; }
# Аудио и обложки отдаёт nginx по заголовку X-Accel-Redirect от api (docs/playback.md).
location /_internal/library/ { internal; alias /library/; sendfile on; aio threads; }
location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on; }
location /_internal/covers/ { internal; alias /cache/covers/; expires 30d; }
location /_internal/uploads/ { internal; alias /uploads/; sendfile on; }
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Обновление / откат на указанную версию: ./update.sh 1.3.0
set -euo pipefail
cd "$(dirname "$0")"
VERSION="${1:?usage: $0 <version>}"
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env
docker compose pull
docker compose up -d --remove-orphans
echo "→ ждём готовности api"
for _ in $(seq 1 60); do
if docker compose exec -T nginx wget -qO- http://localhost/readyz >/dev/null 2>&1; then
echo "pipu $VERSION запущен"
exit 0
fi
sleep 2
done
echo "api не стал готов за 2 минуты: docker compose logs api" >&2
exit 1
+1 -1
View File
@@ -1,6 +1,6 @@
# REST API # REST API
Источник истины — `api/openapi.yaml` (OpenAPI 3.1). Этот документ — обзор и соглашения. Источник истины — `api/openapi.yaml` (OpenAPI 3.0.3 — лучше всего поддерживается oapi-codegen). Этот документ — обзор и соглашения.
## Соглашения ## Соглашения
+3 -2
View File
@@ -8,8 +8,9 @@ Workflows лежат в `.gitea/workflows/`. Синтаксис совмести
- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их - SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их
предлагает только при включённой настройке «бета-версии»). предлагает только при включённой настройке «бета-версии»).
- Одна версия на весь продукт: образы, APK и веб собираются с одним номером. - Одна версия на весь продукт: образы, APK и веб собираются с одним номером.
- Android `versionCode = MAJOR·10000 + MINOR·100 + PATCH` (для rc: `… ·100 + rc` с отдельной схемой, см. скрипт - Android `versionCode = MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N)`,
`scripts/version.ts`). так что `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. - Changelog — `git-cliff` из Conventional Commits.
## `ci.yml` — на каждый push и PR ## `ci.yml` — на каждый push и PR
+1 -1
View File
@@ -83,7 +83,7 @@
**Требования:** **Требования:**
- В манифесте: `REQUEST_INSTALL_PACKAGES`, `FileProvider`. - В манифесте: `REQUEST_INSTALL_PACKAGES`, `FileProvider`.
- Все релизы подписываются **одним и тем же ключом** (keystore в секретах CI) — иначе Android откажет в обновлении. - Все релизы подписываются **одним и тем же ключом** (keystore в секретах CI) — иначе Android откажет в обновлении.
- `versionCode` монотонно растёт: `major·10000 + minor·100 + patch` (см. [ci-cd.md](ci-cd.md)). - `versionCode` монотонно растёт: `major·1 000 000 + minor·10 000 + patch·100 + (99 | rc)` (см. [ci-cd.md](ci-cd.md#версионирование)).
- Ассеты релиза: `pipu-music-<version>.apk`, `pipu-music-<version>.apk.sha256`. - Ассеты релиза: `pipu-music-<version>.apk`, `pipu-music-<version>.apk.sha256`.
## Безопасность ## Безопасность
+22
View File
@@ -0,0 +1,22 @@
{
"name": "pipu-music",
"private": true,
"packageManager": "pnpm@11.9.0",
"engines": {
"node": ">=22"
},
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"generate": "turbo run generate",
"i18n:check": "pnpm --filter @pipu/i18n check",
"version:info": "node scripts/version.mjs",
"test:scripts": "node --test scripts/version.test.mjs"
},
"devDependencies": {
"turbo": "2.5.4",
"typescript": "5.8.3"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@pipu/api-client",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"generate": "openapi-typescript ../../api/openapi.yaml -o src/generated/schema.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"openapi-fetch": "^0.14.0"
},
"devDependencies": {
"openapi-typescript": "^7.8.0"
}
}
+202
View File
@@ -0,0 +1,202 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/healthz": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Живость процесса */
get: operations["getHealthz"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/readyz": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Готовность обслуживать запросы (зависимости доступны) */
get: operations["getReadyz"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/server-info": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Сведения о сервере для клиентов
* @description Вызывается без аутентификации: веб и Android проверяют версию сервера,
* совместимость клиента и поддерживаемые языки (экран подключения).
*/
get: operations["getServerInfo"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
HealthStatus: {
/** @enum {string} */
status: "ok";
};
ReadyStatus: {
/** @enum {string} */
status: "ok" | "degraded";
/** @description Результат проверки каждой зависимости */
checks: {
[key: string]: string;
};
};
ServerInfo: {
/** @example pipu-music */
name: string;
/**
* @description Версия сервера (SemVer)
* @example 0.1.0
*/
version: string;
/** @example v1 */
api_version: string;
/**
* @example [
* "ru",
* "en"
* ]
*/
supported_locales: string[];
/**
* @description Минимальный versionCode Android-приложения, которое поддерживает сервер
* @example 10099
*/
min_android_version_code: number;
};
Problem: {
/** @default about:blank */
type: string;
status: number;
/** @description Машинный код ошибки для i18n, например `auth.invalid_credentials` */
code: string;
/** @description Пояснение для логов (не для показа пользователю) */
detail?: string;
params?: {
[key: string]: unknown;
};
};
};
responses: {
/** @description Ошибка (RFC 9457) */
Problem: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["Problem"];
};
};
};
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
getHealthz: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Процесс жив */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HealthStatus"];
};
};
};
};
getReadyz: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Все зависимости доступны */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReadyStatus"];
};
};
/** @description Одна или несколько зависимостей недоступны */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReadyStatus"];
};
};
};
};
getServerInfo: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Сведения о сервере */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerInfo"];
};
};
default: components["responses"]["Problem"];
};
};
}
+19
View File
@@ -0,0 +1,19 @@
import createClient, { type Client } from 'openapi-fetch';
import type { components, paths } from './generated/schema';
export type { components, paths };
export type ServerInfo = components['schemas']['ServerInfo'];
export type Problem = components['schemas']['Problem'];
export type ApiClient = Client<paths>;
export interface ApiClientOptions {
/** Адрес сервера без завершающего слеша; пусто — тот же origin (веб). */
baseUrl?: string;
fetch?: typeof globalThis.fetch;
}
/** Создаёт типизированный клиент публичного API (контракт — api/openapi.yaml). */
export function createApiClient(opts: ApiClientOptions = {}): ApiClient {
return createClient<paths>({ baseUrl: opts.baseUrl ?? '', fetch: opts.fetch });
}
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2022", "DOM"] }, "include": ["src"] }
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@pipu/core",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@pipu/api-client": "workspace:*"
},
"devDependencies": {
"vitest": "^3.2.4"
}
}
+1
View File
@@ -0,0 +1 @@
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
+59
View File
@@ -0,0 +1,59 @@
import { createApiClient } from '@pipu/api-client';
import { describe, expect, it } from 'vitest';
import { checkServer, normalizeServerUrl } from './server';
describe('normalizeServerUrl', () => {
it.each([
['music.home.lan', 'https://music.home.lan'],
[' https://music.home.lan/ ', 'https://music.home.lan'],
['http://192.168.1.10:8080', 'http://192.168.1.10:8080'],
['https://home.lan/pipu//', 'https://home.lan/pipu'],
])('%s → %s', (input, expected) => {
expect(normalizeServerUrl(input)).toBe(expected);
});
it.each(['', ' ', 'ftp://music.home.lan', 'https://'])('rejects %j', (input) => {
expect(normalizeServerUrl(input)).toBeNull();
});
});
const info = {
name: 'pipu-music',
version: '0.1.0',
api_version: 'v1',
supported_locales: ['ru', 'en'],
min_android_version_code: 100,
};
function clientReturning(response: () => Response | Promise<Response>) {
return createApiClient({ baseUrl: 'https://music.test', fetch: async () => response() });
}
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
describe('checkServer', () => {
it('reports ok for a compatible server', async () => {
await expect(checkServer(clientReturning(() => json(info)), 100)).resolves.toEqual({ status: 'ok', info });
});
it('reports unsupported_app when the app is too old', async () => {
const res = await checkServer(clientReturning(() => json(info)), 99);
expect(res.status).toBe('unsupported_app');
});
it('reports offline on network errors', async () => {
const res = await checkServer(
clientReturning(() => {
throw new TypeError('Failed to fetch');
}),
);
expect(res).toEqual({ status: 'offline', error: 'Failed to fetch' });
});
it('reports offline with the problem code on API errors', async () => {
const res = await checkServer(clientReturning(() => json({ status: 500, code: 'common.internal' }, 500)));
expect(res).toEqual({ status: 'offline', error: 'common.internal' });
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { ApiClient, ServerInfo } from '@pipu/api-client';
/**
* Приводит введённый пользователем адрес сервера к виду `https://host[:port][/path]` без завершающего слеша.
* Без схемы подставляется https. Возвращает null, если адрес некорректен.
*/
export function normalizeServerUrl(input: string): string | null {
const raw = input.trim();
if (!raw) return null;
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
let url: URL;
try {
url = new URL(withScheme);
} catch {
return null;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
if (!url.hostname) return null;
const path = url.pathname.replace(/\/+$/, '');
return `${url.protocol}//${url.host}${path}`;
}
export type ServerCheck =
| { status: 'ok'; info: ServerInfo }
| { status: 'unsupported_app'; info: ServerInfo }
| { status: 'offline'; error: string };
/**
* Проверяет сервер через GET /api/v1/server-info.
* appVersionCode — versionCode Android-клиента; для веба не передаётся.
*/
export async function checkServer(client: ApiClient, appVersionCode?: number): Promise<ServerCheck> {
try {
const { data, error } = await client.GET('/api/v1/server-info');
if (!data) return { status: 'offline', error: error ? error.code : 'unknown' };
if (appVersionCode !== undefined && appVersionCode < data.min_android_version_code) {
return { status: 'unsupported_app', info: data };
}
return { status: 'ok', info: data };
} catch (e) {
return { status: 'offline', error: e instanceof Error ? e.message : String(e) };
}
}
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2022", "DOM"] }, "include": ["src"] }
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@pipu/design-tokens",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./tokens.css": "./src/generated/tokens.css"
},
"scripts": {
"generate": "node scripts/build.mjs",
"typecheck": "tsc --noEmit"
}
}
+36
View File
@@ -0,0 +1,36 @@
// Генерирует CSS-переменные (веб) и TS-объект (React Native) из tokens.json.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const t = JSON.parse(readFileSync(join(root, 'tokens.json'), 'utf8'));
const out = join(root, 'src', 'generated');
mkdirSync(out, { recursive: true });
const vars = (theme) =>
Object.entries({ ...t.color[theme], ...t.effect[theme] })
.map(([k, v]) => ` --${k}: ${v};`)
.join('\n');
const common = [
...Object.entries(t.radius).map(([k, v]) => ` --radius-${k}: ${v}px;`),
...Object.entries(t.wave).map(([k, v]) => ` --wave-${k}: ${v};`),
` --font-display: '${t.font.display}', system-ui, sans-serif;`,
` --font-body: '${t.font.body}', system-ui, sans-serif;`,
` --font-mono: '${t.font.mono}', ui-monospace, monospace;`,
].join('\n');
const header = '/* Сгенерировано scripts/build.mjs из tokens.json — не редактировать. */\n';
const css = `${header}:root {\n${common}\n${vars('dark')}\n}\n
@media (prefers-color-scheme: light) {\n :root:not([data-theme='dark']) {\n${vars('light').replace(/^/gm, ' ')}\n }\n}\n
:root[data-theme='light'] {\n${vars('light')}\n}\n
:root[data-theme='dark'] {\n${vars('dark')}\n}\n`;
writeFileSync(join(out, 'tokens.css'), css);
const ts = `${header}export const tokens = ${JSON.stringify(
{ color: t.color, effect: t.effect, wave: t.wave, radius: t.radius, font: t.font },
null,
2,
)} as const;\n`;
writeFileSync(join(out, 'tokens.ts'), ts);
console.log('design-tokens: generated tokens.css, tokens.ts');
@@ -0,0 +1,145 @@
/* Сгенерировано scripts/build.mjs из tokens.json — не редактировать. */
:root {
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-pill: 999px;
--wave-color1: #2F80FF;
--wave-color2: #7C5CFF;
--wave-color3: #35D3FF;
--font-display: 'Unbounded', system-ui, sans-serif;
--font-body: 'Onest', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--bg: #05070D;
--bg2: #080B14;
--surface: #0B0F1A;
--surface2: #121827;
--surface3: #1A2236;
--elev: #111726;
--hover: rgba(120,150,255,.07);
--line: rgba(130,160,255,.10);
--line2: rgba(130,160,255,.18);
--text: #EDF1FF;
--text2: #A0ABC6;
--text3: #737F9C;
--accent: #2F80FF;
--accent2: #5E9BFF;
--accent-soft: rgba(47,128,255,.15);
--violet: #9B7CFF;
--violet-soft: rgba(124,92,255,.16);
--ok: #2FD39A;
--ok-soft: rgba(47,211,154,.14);
--warn: #FFB547;
--warn-soft: rgba(255,181,71,.14);
--danger: #FF5C70;
--danger-soft: rgba(255,92,112,.14);
--overlay: rgba(3,5,10,.66);
--glass: rgba(12,16,28,.78);
--surface-a: rgba(11,15,26,.70);
--bar-a: rgba(6,9,16,.55);
--shadow: 0 24px 64px rgba(0,0,0,.55);
--glow: 0 8px 28px rgba(47,128,255,.38);
--amb-op: .8;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
--bg: #F3F5FA;
--bg2: #EBEEF5;
--surface: #FFFFFF;
--surface2: #F0F3F9;
--surface3: #E4E9F3;
--elev: #FFFFFF;
--hover: rgba(30,60,140,.06);
--line: rgba(20,35,80,.10);
--line2: rgba(20,35,80,.17);
--text: #0A0F1E;
--text2: #4B5675;
--text3: #677290;
--accent: #1F6FF2;
--accent2: #1F6FF2;
--accent-soft: rgba(31,111,242,.10);
--violet: #6A47F0;
--violet-soft: rgba(106,71,240,.10);
--ok: #0E9465;
--ok-soft: rgba(14,148,101,.10);
--warn: #A86200;
--warn-soft: rgba(168,98,0,.10);
--danger: #D32F47;
--danger-soft: rgba(211,47,71,.09);
--overlay: rgba(20,30,60,.32);
--glass: rgba(255,255,255,.84);
--surface-a: rgba(255,255,255,.74);
--bar-a: rgba(255,255,255,.55);
--shadow: 0 24px 64px rgba(20,30,60,.18);
--glow: 0 8px 24px rgba(31,111,242,.28);
--amb-op: .45;
}
}
:root[data-theme='light'] {
--bg: #F3F5FA;
--bg2: #EBEEF5;
--surface: #FFFFFF;
--surface2: #F0F3F9;
--surface3: #E4E9F3;
--elev: #FFFFFF;
--hover: rgba(30,60,140,.06);
--line: rgba(20,35,80,.10);
--line2: rgba(20,35,80,.17);
--text: #0A0F1E;
--text2: #4B5675;
--text3: #677290;
--accent: #1F6FF2;
--accent2: #1F6FF2;
--accent-soft: rgba(31,111,242,.10);
--violet: #6A47F0;
--violet-soft: rgba(106,71,240,.10);
--ok: #0E9465;
--ok-soft: rgba(14,148,101,.10);
--warn: #A86200;
--warn-soft: rgba(168,98,0,.10);
--danger: #D32F47;
--danger-soft: rgba(211,47,71,.09);
--overlay: rgba(20,30,60,.32);
--glass: rgba(255,255,255,.84);
--surface-a: rgba(255,255,255,.74);
--bar-a: rgba(255,255,255,.55);
--shadow: 0 24px 64px rgba(20,30,60,.18);
--glow: 0 8px 24px rgba(31,111,242,.28);
--amb-op: .45;
}
:root[data-theme='dark'] {
--bg: #05070D;
--bg2: #080B14;
--surface: #0B0F1A;
--surface2: #121827;
--surface3: #1A2236;
--elev: #111726;
--hover: rgba(120,150,255,.07);
--line: rgba(130,160,255,.10);
--line2: rgba(130,160,255,.18);
--text: #EDF1FF;
--text2: #A0ABC6;
--text3: #737F9C;
--accent: #2F80FF;
--accent2: #5E9BFF;
--accent-soft: rgba(47,128,255,.15);
--violet: #9B7CFF;
--violet-soft: rgba(124,92,255,.16);
--ok: #2FD39A;
--ok-soft: rgba(47,211,154,.14);
--warn: #FFB547;
--warn-soft: rgba(255,181,71,.14);
--danger: #FF5C70;
--danger-soft: rgba(255,92,112,.14);
--overlay: rgba(3,5,10,.66);
--glass: rgba(12,16,28,.78);
--surface-a: rgba(11,15,26,.70);
--bar-a: rgba(6,9,16,.55);
--shadow: 0 24px 64px rgba(0,0,0,.55);
--glow: 0 8px 28px rgba(47,128,255,.38);
--amb-op: .8;
}
@@ -0,0 +1,92 @@
/* Сгенерировано scripts/build.mjs из tokens.json — не редактировать. */
export const tokens = {
"color": {
"dark": {
"bg": "#05070D",
"bg2": "#080B14",
"surface": "#0B0F1A",
"surface2": "#121827",
"surface3": "#1A2236",
"elev": "#111726",
"hover": "rgba(120,150,255,.07)",
"line": "rgba(130,160,255,.10)",
"line2": "rgba(130,160,255,.18)",
"text": "#EDF1FF",
"text2": "#A0ABC6",
"text3": "#737F9C",
"accent": "#2F80FF",
"accent2": "#5E9BFF",
"accent-soft": "rgba(47,128,255,.15)",
"violet": "#9B7CFF",
"violet-soft": "rgba(124,92,255,.16)",
"ok": "#2FD39A",
"ok-soft": "rgba(47,211,154,.14)",
"warn": "#FFB547",
"warn-soft": "rgba(255,181,71,.14)",
"danger": "#FF5C70",
"danger-soft": "rgba(255,92,112,.14)",
"overlay": "rgba(3,5,10,.66)",
"glass": "rgba(12,16,28,.78)",
"surface-a": "rgba(11,15,26,.70)",
"bar-a": "rgba(6,9,16,.55)"
},
"light": {
"bg": "#F3F5FA",
"bg2": "#EBEEF5",
"surface": "#FFFFFF",
"surface2": "#F0F3F9",
"surface3": "#E4E9F3",
"elev": "#FFFFFF",
"hover": "rgba(30,60,140,.06)",
"line": "rgba(20,35,80,.10)",
"line2": "rgba(20,35,80,.17)",
"text": "#0A0F1E",
"text2": "#4B5675",
"text3": "#677290",
"accent": "#1F6FF2",
"accent2": "#1F6FF2",
"accent-soft": "rgba(31,111,242,.10)",
"violet": "#6A47F0",
"violet-soft": "rgba(106,71,240,.10)",
"ok": "#0E9465",
"ok-soft": "rgba(14,148,101,.10)",
"warn": "#A86200",
"warn-soft": "rgba(168,98,0,.10)",
"danger": "#D32F47",
"danger-soft": "rgba(211,47,71,.09)",
"overlay": "rgba(20,30,60,.32)",
"glass": "rgba(255,255,255,.84)",
"surface-a": "rgba(255,255,255,.74)",
"bar-a": "rgba(255,255,255,.55)"
}
},
"effect": {
"dark": {
"shadow": "0 24px 64px rgba(0,0,0,.55)",
"glow": "0 8px 28px rgba(47,128,255,.38)",
"amb-op": ".8"
},
"light": {
"shadow": "0 24px 64px rgba(20,30,60,.18)",
"glow": "0 8px 24px rgba(31,111,242,.28)",
"amb-op": ".45"
}
},
"wave": {
"color1": "#2F80FF",
"color2": "#7C5CFF",
"color3": "#35D3FF"
},
"radius": {
"sm": 8,
"md": 12,
"lg": 16,
"xl": 24,
"pill": 999
},
"font": {
"display": "Unbounded",
"body": "Onest",
"mono": "JetBrains Mono"
}
} as const;
+3
View File
@@ -0,0 +1,3 @@
export { tokens } from './generated/tokens';
export type ThemeName = 'dark' | 'light';
+32
View File
@@ -0,0 +1,32 @@
{
"$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate",
"color": {
"dark": {
"bg": "#05070D", "bg2": "#080B14", "surface": "#0B0F1A", "surface2": "#121827", "surface3": "#1A2236",
"elev": "#111726", "hover": "rgba(120,150,255,.07)", "line": "rgba(130,160,255,.10)", "line2": "rgba(130,160,255,.18)",
"text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#737F9C",
"accent": "#2F80FF", "accent2": "#5E9BFF", "accent-soft": "rgba(47,128,255,.15)",
"violet": "#9B7CFF", "violet-soft": "rgba(124,92,255,.16)",
"ok": "#2FD39A", "ok-soft": "rgba(47,211,154,.14)", "warn": "#FFB547", "warn-soft": "rgba(255,181,71,.14)",
"danger": "#FF5C70", "danger-soft": "rgba(255,92,112,.14)",
"overlay": "rgba(3,5,10,.66)", "glass": "rgba(12,16,28,.78)", "surface-a": "rgba(11,15,26,.70)", "bar-a": "rgba(6,9,16,.55)"
},
"light": {
"bg": "#F3F5FA", "bg2": "#EBEEF5", "surface": "#FFFFFF", "surface2": "#F0F3F9", "surface3": "#E4E9F3",
"elev": "#FFFFFF", "hover": "rgba(30,60,140,.06)", "line": "rgba(20,35,80,.10)", "line2": "rgba(20,35,80,.17)",
"text": "#0A0F1E", "text2": "#4B5675", "text3": "#677290",
"accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-soft": "rgba(31,111,242,.10)",
"violet": "#6A47F0", "violet-soft": "rgba(106,71,240,.10)",
"ok": "#0E9465", "ok-soft": "rgba(14,148,101,.10)", "warn": "#A86200", "warn-soft": "rgba(168,98,0,.10)",
"danger": "#D32F47", "danger-soft": "rgba(211,47,71,.09)",
"overlay": "rgba(20,30,60,.32)", "glass": "rgba(255,255,255,.84)", "surface-a": "rgba(255,255,255,.74)", "bar-a": "rgba(255,255,255,.55)"
}
},
"effect": {
"dark": { "shadow": "0 24px 64px rgba(0,0,0,.55)", "glow": "0 8px 28px rgba(47,128,255,.38)", "amb-op": ".8" },
"light": { "shadow": "0 24px 64px rgba(20,30,60,.18)", "glow": "0 8px 24px rgba(31,111,242,.28)", "amb-op": ".45" }
},
"wave": { "color1": "#2F80FF", "color2": "#7C5CFF", "color3": "#35D3FF" },
"radius": { "sm": 8, "md": 12, "lg": 16, "xl": 24, "pill": 999 },
"font": { "display": "Unbounded", "body": "Onest", "mono": "JetBrains Mono" }
}
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "include": ["src"] }
+21
View File
@@ -0,0 +1,21 @@
{
"app": {
"name": "pipu",
"tagline": "Your music from your own server"
},
"server": {
"title": "Server",
"checking": "Checking the server…",
"online": "Server {{version}} is online",
"offline": "The server is not responding. Check the address and your network.",
"address": "Server address",
"check": "Check",
"invalid_address": "Enter the server address, e.g. https://music.home.lan",
"unsupported_app": "This app version is too old for the server — please update the app"
},
"language": {
"label": "Language",
"ru": "Русский",
"en": "English"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"app": {
"name": "pipu",
"tagline": "Ваша музыка с вашего сервера"
},
"server": {
"title": "Сервер",
"checking": "Проверяем сервер…",
"online": "Сервер {{version}} на связи",
"offline": "Сервер не отвечает. Проверьте адрес и сеть.",
"address": "Адрес сервера",
"check": "Проверить",
"invalid_address": "Введите адрес сервера, например https://music.home.lan",
"unsupported_app": "Эта версия приложения слишком старая для сервера — обновите приложение"
},
"language": {
"label": "Язык",
"ru": "Русский",
"en": "English"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@pipu/i18n",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
"scripts": {
"check": "node scripts/check.mjs",
"test": "node scripts/check.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"i18next": "^25.2.1"
}
}
+37
View File
@@ -0,0 +1,37 @@
// Проверяет словари: одинаковые ключи во всех языках и совпадение плейсхолдеров {{...}}.
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const dir = join(dirname(fileURLToPath(import.meta.url)), '..', 'locales');
const locales = readdirSync(dir);
const flat = (obj, prefix = '') =>
Object.entries(obj).flatMap(([k, v]) =>
typeof v === 'object' ? flat(v, `${prefix}${k}.`) : [[`${prefix}${k}`, String(v)]],
);
const placeholders = (s) => [...s.matchAll(/\{\{\s*(\w+)\s*\}\}/g)].map((m) => m[1]).sort().join(',');
const data = {};
for (const loc of locales) {
for (const file of readdirSync(join(dir, loc))) {
const ns = file.replace(/\.json$/, '');
for (const [k, v] of flat(JSON.parse(readFileSync(join(dir, loc, file), 'utf8')))) {
(data[`${ns}:${k}`] ??= {})[loc] = v;
}
}
}
const errors = [];
for (const [key, byLoc] of Object.entries(data)) {
for (const loc of locales) {
if (!(loc in byLoc)) errors.push(`${loc}: отсутствует ключ ${key}`);
}
const sets = new Set(Object.values(byLoc).map(placeholders));
if (sets.size > 1) errors.push(`${key}: плейсхолдеры различаются между языками`);
}
if (errors.length) {
console.error(errors.join('\n'));
process.exit(1);
}
console.log(`i18n: ${Object.keys(data).length} ключей × ${locales.length} языка — ок`);
+47
View File
@@ -0,0 +1,47 @@
import i18next, { type i18n as I18n } from 'i18next';
import enCommon from '../locales/en/common.json';
import ruCommon from '../locales/ru/common.json';
/** Поддерживаемые языки. Новый язык = папка в locales/ + строка здесь. */
export const SUPPORTED_LOCALES = ['ru', 'en'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'en';
export const resources = {
ru: { common: ruCommon },
en: { common: enCommon },
} as const;
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: (typeof resources)['ru'];
}
}
/** Выбирает поддерживаемый язык из списка предпочтений (navigator.languages, настройки ОС). */
export function pickLocale(preferred: readonly string[]): Locale {
for (const tag of preferred) {
const base = tag.toLowerCase().split('-')[0];
const match = SUPPORTED_LOCALES.find((l) => l === base);
if (match) return match;
}
return DEFAULT_LOCALE;
}
/** Создаёт и инициализирует экземпляр i18next. */
export async function createI18n(locale: Locale): Promise<I18n> {
const instance = i18next.createInstance();
await instance.init({
lng: locale,
fallbackLng: DEFAULT_LOCALE,
supportedLngs: [...SUPPORTED_LOCALES],
defaultNS: 'common',
ns: ['common'],
resources,
interpolation: { escapeValue: false },
returnNull: false,
});
return instance;
}
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "include": ["src", "locales"] }
+7210
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
packages:
- apps/*
- packages/*
# Пакеты, которым разрешено выполнять install-скрипты
allowBuilds:
esbuild: true
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
// Вычисляет версию продукта из git-тега (docs/ci-cd.md#версионирование).
// node scripts/version.mjs v1.2.3 → печатает KEY=VALUE (для $GITHUB_ENV / $GITHUB_OUTPUT)
// node scripts/version.mjs → берёт GITHUB_REF_NAME, иначе 0.0.0-dev
//
// 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.
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const TAG_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/;
export function parseVersion(tag) {
const m = TAG_RE.exec(tag.trim());
if (!m) throw new Error(`not a release tag: ${tag} (expected vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N)`);
const [major, minor, patch] = [m[1], m[2], m[3]].map(Number);
const rc = m[4] === undefined ? null : Number(m[4]);
if (minor > 99 || patch > 99) throw new Error(`minor and patch must be <= 99: ${tag}`);
if (rc !== null && (rc < 1 || rc > 98)) throw new Error(`rc number must be 1..98: ${tag}`);
const version = `${major}.${minor}.${patch}${rc === null ? '' : `-rc.${rc}`}`;
const versionCode = major * 1_000_000 + minor * 10_000 + patch * 100 + (rc ?? 99);
return { version, versionCode, prerelease: rc !== null };
}
function main() {
const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME ?? '';
const v = TAG_RE.test(tag) ? parseVersion(tag) : { version: '0.0.0-dev', versionCode: 1, prerelease: true };
process.stdout.write(
`PIPU_VERSION=${v.version}\nPIPU_VERSION_CODE=${v.versionCode}\nPIPU_PRERELEASE=${v.prerelease}\n`,
);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
main();
}
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { parseVersion } from './version.mjs';
test('release tag', () => {
assert.deepEqual(parseVersion('v0.1.0'), { version: '0.1.0', versionCode: 10_099, prerelease: false });
assert.deepEqual(parseVersion('v1.2.3'), { version: '1.2.3', versionCode: 1_020_399, prerelease: false });
});
test('release candidate', () => {
assert.deepEqual(parseVersion('v1.2.0-rc.2'), { version: '1.2.0-rc.2', versionCode: 1_020_002, prerelease: true });
});
test('version codes grow monotonically', () => {
const order = ['v0.9.9', 'v1.0.0-rc.1', 'v1.0.0-rc.2', 'v1.0.0', 'v1.0.1', 'v1.1.0', 'v2.0.0'];
const codes = order.map((t) => parseVersion(t).versionCode);
assert.deepEqual([...codes].sort((a, b) => a - b), codes);
});
test('rejects malformed tags', () => {
for (const tag of ['1.2', 'v1.2.3-beta', 'v1.100.0', 'v1.2.3-rc.0', 'main']) {
assert.throws(() => parseVersion(tag), undefined, tag);
}
});
+27
View File
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1
# Сборка из корня репозитория:
# docker build -f services/api/Dockerfile --target api --build-arg VERSION=1.2.3 .
# docker build -f services/api/Dockerfile --target storage-agent --build-arg VERSION=1.2.3 .
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY services/api/go.mod services/api/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY services/api/ ./
ARG VERSION=0.0.0-dev
ENV CGO_ENABLED=0
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/api ./cmd/api && \
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/storage-agent ./cmd/storage-agent
FROM gcr.io/distroless/static-debian12:nonroot AS api
COPY --from=build /out/api /usr/local/bin/api
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/api"]
# Агенту нужны nfs-utils для mount -t nfs и root внутри контейнера (CAP_SYS_ADMIN задаётся в compose).
FROM alpine:3.22 AS storage-agent
RUN apk add --no-cache nfs-utils ca-certificates
COPY --from=build /out/storage-agent /usr/local/bin/storage-agent
EXPOSE 8090
ENTRYPOINT ["/usr/local/bin/storage-agent"]
+99
View File
@@ -0,0 +1,99 @@
// Команда api — публичный REST API Pipu Music.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/redis/go-redis/v9"
"pipu/api/internal/buildinfo"
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/db"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/server"
"pipu/api/internal/system"
)
func main() {
if err := run(); err != nil {
slog.Error("api stopped", "err", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.Load[config.API]()
if err != nil {
return err
}
log := httpx.NewLogger(cfg.LogLevel)
slog.SetDefault(log)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := db.Connect(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()
if cfg.MigrateOnStart {
if err := db.Migrate(ctx, pool); err != nil {
return err
}
log.Info("migrations applied")
}
redisOpts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
return err
}
rdb := redis.NewClient(redisOpts)
defer func() { _ = rdb.Close() }()
sys := system.NewHandler(system.Info{
Version: buildinfo.Version,
SupportedLocales: cfg.SupportedLocales,
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
}, map[string]system.Checker{
"postgres": system.CheckerFunc(pool.Ping),
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
})
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: server.NewRouter(server.Deps{Log: log, System: sys}),
ReadHeaderTimeout: 10 * time.Second,
}
return serve(ctx, log, srv, cfg.ShutdownTimeout)
}
func serve(ctx context.Context, log *slog.Logger, srv *http.Server, shutdownTimeout time.Duration) error {
errCh := make(chan error, 1)
go func() {
log.Info("listening", "addr", srv.Addr, "version", buildinfo.Version)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
close(errCh)
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
+34
View File
@@ -0,0 +1,34 @@
// Команда storage-agent — внутренний привилегированный сервис монтирования хранилищ.
package main
import (
"log/slog"
"net/http"
"os"
"time"
"pipu/api/internal/buildinfo"
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/storageagent"
)
func main() {
cfg, err := config.Load[config.StorageAgent]()
if err != nil {
slog.Error("storage-agent: config", "err", err)
os.Exit(1)
}
log := httpx.NewLogger(cfg.LogLevel)
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: storageagent.NewRouter(cfg.InternalToken, cfg.LibraryRoot),
ReadHeaderTimeout: 10 * time.Second,
}
log.Info("listening", "addr", srv.Addr, "version", buildinfo.Version)
if err := srv.ListenAndServe(); err != nil {
log.Error("storage-agent stopped", "err", err)
os.Exit(1)
}
}
+47
View File
@@ -0,0 +1,47 @@
module pipu/api
go 1.24.4
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
require (
github.com/caarlos0/env/v11 v11.3.1
github.com/getkin/kin-openapi v0.127.0
github.com/go-chi/chi/v5 v5.2.2
github.com/jackc/pgx/v5 v5.7.5
github.com/oapi-codegen/runtime v1.1.1
github.com/pressly/goose/v3 v3.24.3
github.com/redis/go-redis/v9 v9.10.0
github.com/stretchr/testify v1.10.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+226
View File
@@ -0,0 +1,226 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs=
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
+571
View File
@@ -0,0 +1,571 @@
// Package gen provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT.
package gen
import (
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-chi/chi/v5"
strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp"
)
// Defines values for HealthStatusStatus.
const (
HealthStatusStatusOk HealthStatusStatus = "ok"
)
// Defines values for ReadyStatusStatus.
const (
ReadyStatusStatusDegraded ReadyStatusStatus = "degraded"
ReadyStatusStatusOk ReadyStatusStatus = "ok"
)
// HealthStatus defines model for HealthStatus.
type HealthStatus struct {
Status HealthStatusStatus `json:"status"`
}
// HealthStatusStatus defines model for HealthStatus.Status.
type HealthStatusStatus string
// Problem defines model for Problem.
type Problem struct {
// Code Машинный код ошибки для i18n, например `auth.invalid_credentials`
Code string `json:"code"`
// Detail Пояснение для логов (не для показа пользователю)
Detail *string `json:"detail,omitempty"`
Params *map[string]interface{} `json:"params,omitempty"`
Status int `json:"status"`
Type *string `json:"type,omitempty"`
}
// ReadyStatus defines model for ReadyStatus.
type ReadyStatus struct {
// Checks Результат проверки каждой зависимости
Checks map[string]string `json:"checks"`
Status ReadyStatusStatus `json:"status"`
}
// ReadyStatusStatus defines model for ReadyStatus.Status.
type ReadyStatusStatus string
// ServerInfo defines model for ServerInfo.
type ServerInfo struct {
ApiVersion string `json:"api_version"`
// MinAndroidVersionCode Минимальный versionCode Android-приложения, которое поддерживает сервер
MinAndroidVersionCode int `json:"min_android_version_code"`
Name string `json:"name"`
SupportedLocales []string `json:"supported_locales"`
// Version Версия сервера (SemVer)
Version string `json:"version"`
}
// ServerInterface represents all server handlers.
type ServerInterface interface {
// Сведения о сервере для клиентов
// (GET /api/v1/server-info)
GetServerInfo(w http.ResponseWriter, r *http.Request)
// Живость процесса
// (GET /healthz)
GetHealthz(w http.ResponseWriter, r *http.Request)
// Готовность обслуживать запросы (зависимости доступны)
// (GET /readyz)
GetReadyz(w http.ResponseWriter, r *http.Request)
}
// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint.
type Unimplemented struct{}
// Сведения о сервере для клиентов
// (GET /api/v1/server-info)
func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Живость процесса
// (GET /healthz)
func (_ Unimplemented) GetHealthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Готовность обслуживать запросы (зависимости доступны)
// (GET /readyz)
func (_ Unimplemented) GetReadyz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// ServerInterfaceWrapper converts contexts to parameters.
type ServerInterfaceWrapper struct {
Handler ServerInterface
HandlerMiddlewares []MiddlewareFunc
ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
}
type MiddlewareFunc func(http.Handler) http.Handler
// GetServerInfo operation middleware
func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetServerInfo(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetHealthz operation middleware
func (siw *ServerInterfaceWrapper) GetHealthz(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetHealthz(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetReadyz operation middleware
func (siw *ServerInterfaceWrapper) GetReadyz(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetReadyz(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
type UnescapedCookieParamError struct {
ParamName string
Err error
}
func (e *UnescapedCookieParamError) Error() string {
return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName)
}
func (e *UnescapedCookieParamError) Unwrap() error {
return e.Err
}
type UnmarshalingParamError struct {
ParamName string
Err error
}
func (e *UnmarshalingParamError) Error() string {
return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error())
}
func (e *UnmarshalingParamError) Unwrap() error {
return e.Err
}
type RequiredParamError struct {
ParamName string
}
func (e *RequiredParamError) Error() string {
return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName)
}
type RequiredHeaderError struct {
ParamName string
Err error
}
func (e *RequiredHeaderError) Error() string {
return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName)
}
func (e *RequiredHeaderError) Unwrap() error {
return e.Err
}
type InvalidParamFormatError struct {
ParamName string
Err error
}
func (e *InvalidParamFormatError) Error() string {
return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error())
}
func (e *InvalidParamFormatError) Unwrap() error {
return e.Err
}
type TooManyValuesForParamError struct {
ParamName string
Count int
}
func (e *TooManyValuesForParamError) Error() string {
return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count)
}
// Handler creates http.Handler with routing matching OpenAPI spec.
func Handler(si ServerInterface) http.Handler {
return HandlerWithOptions(si, ChiServerOptions{})
}
type ChiServerOptions struct {
BaseURL string
BaseRouter chi.Router
Middlewares []MiddlewareFunc
ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
}
// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux.
func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler {
return HandlerWithOptions(si, ChiServerOptions{
BaseRouter: r,
})
}
func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler {
return HandlerWithOptions(si, ChiServerOptions{
BaseURL: baseURL,
BaseRouter: r,
})
}
// HandlerWithOptions creates http.Handler with additional options
func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler {
r := options.BaseRouter
if r == nil {
r = chi.NewRouter()
}
if options.ErrorHandlerFunc == nil {
options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) {
http.Error(w, err.Error(), http.StatusBadRequest)
}
}
wrapper := ServerInterfaceWrapper{
Handler: si,
HandlerMiddlewares: options.Middlewares,
ErrorHandlerFunc: options.ErrorHandlerFunc,
}
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/server-info", wrapper.GetServerInfo)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/healthz", wrapper.GetHealthz)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/readyz", wrapper.GetReadyz)
})
return r
}
type ProblemApplicationProblemPlusJSONResponse Problem
type GetServerInfoRequestObject struct {
}
type GetServerInfoResponseObject interface {
VisitGetServerInfoResponse(w http.ResponseWriter) error
}
type GetServerInfo200JSONResponse ServerInfo
func (response GetServerInfo200JSONResponse) VisitGetServerInfoResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetServerInfodefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response GetServerInfodefaultApplicationProblemPlusJSONResponse) VisitGetServerInfoResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type GetHealthzRequestObject struct {
}
type GetHealthzResponseObject interface {
VisitGetHealthzResponse(w http.ResponseWriter) error
}
type GetHealthz200JSONResponse HealthStatus
func (response GetHealthz200JSONResponse) VisitGetHealthzResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetReadyzRequestObject struct {
}
type GetReadyzResponseObject interface {
VisitGetReadyzResponse(w http.ResponseWriter) error
}
type GetReadyz200JSONResponse ReadyStatus
func (response GetReadyz200JSONResponse) VisitGetReadyzResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetReadyz503JSONResponse ReadyStatus
func (response GetReadyz503JSONResponse) VisitGetReadyzResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(503)
return json.NewEncoder(w).Encode(response)
}
// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
// Сведения о сервере для клиентов
// (GET /api/v1/server-info)
GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error)
// Живость процесса
// (GET /healthz)
GetHealthz(ctx context.Context, request GetHealthzRequestObject) (GetHealthzResponseObject, error)
// Готовность обслуживать запросы (зависимости доступны)
// (GET /readyz)
GetReadyz(ctx context.Context, request GetReadyzRequestObject) (GetReadyzResponseObject, error)
}
type StrictHandlerFunc = strictnethttp.StrictHTTPHandlerFunc
type StrictMiddlewareFunc = strictnethttp.StrictHTTPMiddlewareFunc
type StrictHTTPServerOptions struct {
RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error)
}
func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface {
return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{
RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) {
http.Error(w, err.Error(), http.StatusBadRequest)
},
ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) {
http.Error(w, err.Error(), http.StatusInternalServerError)
},
}}
}
func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface {
return &strictHandler{ssi: ssi, middlewares: middlewares, options: options}
}
type strictHandler struct {
ssi StrictServerInterface
middlewares []StrictMiddlewareFunc
options StrictHTTPServerOptions
}
// GetServerInfo operation middleware
func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) {
var request GetServerInfoRequestObject
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetServerInfo(ctx, request.(GetServerInfoRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetServerInfo")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetServerInfoResponseObject); ok {
if err := validResponse.VisitGetServerInfoResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// GetHealthz operation middleware
func (sh *strictHandler) GetHealthz(w http.ResponseWriter, r *http.Request) {
var request GetHealthzRequestObject
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetHealthz(ctx, request.(GetHealthzRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetHealthz")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetHealthzResponseObject); ok {
if err := validResponse.VisitGetHealthzResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// GetReadyz operation middleware
func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
var request GetReadyzRequestObject
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetReadyz(ctx, request.(GetReadyzRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetReadyz")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetReadyzResponseObject); ok {
if err := validResponse.VisitGetReadyzResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"H4sIAAAAAAAC/7xWX28bRRD/KquFh1hcbIdSQf1WVUD7gBQliJc2SjZ322Sb+8feXkSILMUJapFSGqkg",
"xANq6Tdwgt0cdn35CrNfgU+CZu/OvnMuITyUJ/t2Z3fm95vfzM4+tQMvDHzuq4h29qnkURj4ETcfyzLY",
"dLmHf+3AV9xX+JeFoStspkTgt8LM4qMnUeDjXmRvc4/hvw8lf0w79IPW7P5Wthu1inu73a5FHR7ZUoR4",
"He1QeKV/hAROYQR9srDyxT1y55PbnzYoWubH8fb7nLlqe1UxFZvvUAYhl0pkcUfTde7HHu08pMEOXbOo",
"2gs57dBISeFvmSsl/zYWkjtok5+a2QWbT7itaNcqE1H1ZAcOx985EL9D38CYwEQfw18ERpDCgEA6BZcQ",
"GMBYnxCx9JlvEZhAHy70ASTwDob6gGywWG03hb/LXOGs25I73FeCudEGvYQDOVRMuDWBvIZUn+geTGAI",
"E0hgWLiFMaTwJ6RwRhZwd7p+ASlyD+fQzz7G+jmcoyH09SEMYaxfNOpiCJlkniGFOY7AAJi7XCJLyZjX",
"UDvLVb4lfMW3uMS9bAVBPWaxq2iHss0gVp1Nl/k79Ib5tLIk1aV1hTNn7yoR2dvc3rkGzzzXG8HOBoEE",
"xpAQQ9RI9/RhJef1qask7A8Ywrk+Qtb1ITJOjCyQ/qE+yISD6XkLA0hRWefQhzNIdM9IJ0WfZUd1RJeK",
"Av1vSeZw58b1YRXM1DG6yuUulw/8x8FlQlko1ne5jETWKvh3zAtdPL67VEeMJ/x15jsyEE5xbP3KckuM",
"ut9B38g1K7r80L3A4eRudtFiXmMo/rdZSegTy5SnPoTUED3MZD+AgWH8LSQofRjqQ6J7ZsmkglozBEvt",
"9p07Vo2AfebxKtZQhPGiF0fCrsMcxWEYSMWddTewmZvxNj38kMoY3fpIvVDcK5fN7JJ8gUnJ9vC7xPkc",
"by8RCCpHn1SwYetd5d43XDbKMGm7udRs/2vdGdAzt1Yl8XUYr8n1ZY2hN5Hra77Z6SM4xfrTz3IJrHy+",
"+jW5u/yALIswJl8h7U0Cv5kiSdEKEhgRrB6sGjxU9MF5OgIWikUMaYv7jUd+VoZj7KgwwcuwjwYh99EM",
"I84CazQJvDGNdmxehFxx5O+DX4gT2FGLhaLpOc1HSIwSyrA8ixVDLzFZJKBr0dwV7dBbzXbzFsX2q7aN",
"HvDO1u5SKzKluFhwtcVVnQL0MZzr40LhuodPwCn2IAJ9fWT6GOJL9A9IFfT1U0gg6RBkBk4JJEVpVfqU",
"PtEvsHWdTQX2Yo5R65Gve8Yc37uM/rx96edVbvvopb4m4Z0+hiHRJwYENscF/ROM0AFMijMjfLL0s4L8",
"RsY2diYzwzxwaId+yVWpdVnVMejjdvuaEei/jT4lL3XTzxvD62AqFEirvA2zJyN/DutdTWMvzVlYdp7H",
"5N6NnEyHgTmJo0jZVmQeg71IcY+u4dWtbTONfV+S2SV27+cm75HaykxYR+5rFKh+avTWI5mK5rn51Uhr",
"qsOL8hHoX0WAxEHiWvwrmcV7hF8eZurQv8QcXzEvYMIzzEdwgW0QZXa7fet/C+4VDHAELuYnnEh1z0zN",
"+J6PIK0PfIgT0MSIeS78alZ/zt53OIPJLLcpnOoejPVR0U+y5fN8Eke7Y7JwQ8IatdLAKEzB4/o+jaVL",
"O7RFu2tT4/18RigOdde6/wQAAP//nG+aXpMNAAA=",
}
// GetSwagger returns the content of the embedded swagger specification file
// or error if failed to decode
func decodeSpec() ([]byte, error) {
zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, ""))
if err != nil {
return nil, fmt.Errorf("error base64 decoding spec: %w", err)
}
zr, err := gzip.NewReader(bytes.NewReader(zipped))
if err != nil {
return nil, fmt.Errorf("error decompressing spec: %w", err)
}
var buf bytes.Buffer
_, err = buf.ReadFrom(zr)
if err != nil {
return nil, fmt.Errorf("error decompressing spec: %w", err)
}
return buf.Bytes(), nil
}
var rawSpec = decodeSpecCached()
// a naive cached of a decoded swagger spec
func decodeSpecCached() func() ([]byte, error) {
data, err := decodeSpec()
return func() ([]byte, error) {
return data, err
}
}
// Constructs a synthetic filesystem for resolving external references when loading openapi specifications.
func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) {
res := make(map[string]func() ([]byte, error))
if len(pathToFile) > 0 {
res[pathToFile] = rawSpec
}
return res
}
// GetSwagger returns the Swagger specification corresponding to the generated code
// in this file. The external references of Swagger specification are resolved.
// The logic of resolving external references is tightly connected to "import-mapping" feature.
// Externally referenced files must be embedded in the corresponding golang packages.
// Urls can be supported but this task was out of the scope.
func GetSwagger() (swagger *openapi3.T, err error) {
resolvePath := PathToRawSpec("")
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = true
loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) {
pathToFile := url.String()
pathToFile = path.Clean(pathToFile)
getSpec, ok := resolvePath[pathToFile]
if !ok {
err1 := fmt.Errorf("path not found: %s", pathToFile)
return nil, err1
}
return getSpec()
}
var specData []byte
specData, err = rawSpec()
if err != nil {
return
}
swagger, err = loader.LoadFromData(specData)
if err != nil {
return
}
return
}
@@ -0,0 +1,4 @@
// Package gen содержит код, сгенерированный из api/openapi.yaml. Не редактировать вручную.
package gen
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../../../../api/openapi.yaml
@@ -0,0 +1,7 @@
package: gen
output: api.gen.go
generate:
models: true
chi-server: true
strict-server: true
embedded-spec: true
@@ -0,0 +1,6 @@
// Package buildinfo хранит версию сборки, проставляемую через -ldflags.
package buildinfo
// Version — версия продукта (SemVer). В релизной сборке:
// go build -ldflags "-X pipu/api/internal/buildinfo.Version=1.2.3"
var Version = "0.0.0-dev"
@@ -0,0 +1,39 @@
// Package config читает конфигурацию сервиса из переменных окружения.
package config
import (
"fmt"
"time"
"github.com/caarlos0/env/v11"
)
// API — конфигурация публичного сервиса api.
type API struct {
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
RedisURL string `env:"REDIS_URL,required"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
MigrateOnStart bool `env:"MIGRATE_ON_START" envDefault:"true"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
SupportedLocales []string `env:"SUPPORTED_LOCALES" envDefault:"ru,en" envSeparator:","`
MinAndroidVersionCode int `env:"MIN_ANDROID_VERSION_CODE" envDefault:"0"`
}
// StorageAgent — конфигурация внутреннего сервиса storage-agent.
type StorageAgent struct {
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"`
InternalToken string `env:"INTERNAL_TOKEN,required"`
LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
}
// Load заполняет структуру конфигурации T из окружения.
func Load[T any]() (T, error) {
var cfg T
if err := env.Parse(&cfg); err != nil {
return cfg, fmt.Errorf("config: %w", err)
}
return cfg, nil
}
@@ -0,0 +1,26 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadAPIDefaults(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://localhost/pipu")
t.Setenv("REDIS_URL", "redis://localhost:6379/0")
cfg, err := Load[API]()
require.NoError(t, err)
assert.Equal(t, ":8080", cfg.HTTPAddr)
assert.Equal(t, []string{"ru", "en"}, cfg.SupportedLocales)
assert.True(t, cfg.MigrateOnStart)
}
func TestLoadAPIRequiresDatabase(t *testing.T) {
t.Setenv("REDIS_URL", "redis://localhost:6379/0")
_, err := Load[API]()
assert.ErrorContains(t, err, "DATABASE_URL")
}
+42
View File
@@ -0,0 +1,42 @@
// Package db — подключение к PostgreSQL и применение миграций.
package db
import (
"context"
"database/sql"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"pipu/api/migrations"
)
// Connect открывает пул соединений и проверяет доступность БД.
func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, url)
if err != nil {
return nil, fmt.Errorf("db: open pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("db: ping: %w", err)
}
return pool, nil
}
// Migrate применяет встроенные миграции goose.
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
sqlDB := stdlib.OpenDBFromPool(pool)
defer func(d *sql.DB) { _ = d.Close() }(sqlDB)
provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, migrations.FS)
if err != nil {
return fmt.Errorf("db: migrations: %w", err)
}
if _, err := provider.Up(ctx); err != nil {
return fmt.Errorf("db: migrate up: %w", err)
}
return nil
}
@@ -0,0 +1,43 @@
package httpx
import (
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
)
// NewLogger создаёт JSON-логгер с заданным уровнем (debug, info, warn, error).
func NewLogger(level string) *slog.Logger {
var lvl slog.Level
if err := lvl.UnmarshalText([]byte(strings.ToUpper(level))); err != nil {
lvl = slog.LevelInfo
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
}
// AccessLog пишет строку лога на каждый запрос, кроме проб здоровья.
func AccessLog(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
next.ServeHTTP(w, r)
return
}
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
log.InfoContext(r.Context(), "http",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"bytes", ww.BytesWritten(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
)
})
}
}
@@ -0,0 +1,47 @@
// Package httpx — общие HTTP-утилиты: ответы об ошибках (RFC 9457), логирование, middleware.
package httpx
import (
"encoding/json"
"log/slog"
"net/http"
)
// Problem — тело ответа application/problem+json. Поле Code переводится клиентом через i18n.
type Problem struct {
Type string `json:"type"`
Status int `json:"status"`
Code string `json:"code"`
Detail string `json:"detail,omitempty"`
Params map[string]any `json:"params,omitempty"`
}
// WriteProblem пишет ответ об ошибке.
func WriteProblem(w http.ResponseWriter, status int, code, detail string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(Problem{Type: "about:blank", Status: status, Code: code, Detail: detail})
}
// NotFound — обработчик неизвестных путей.
func NotFound(w http.ResponseWriter, _ *http.Request) {
WriteProblem(w, http.StatusNotFound, "common.not_found", "route not found")
}
// MethodNotAllowed — обработчик неподдерживаемых методов.
func MethodNotAllowed(w http.ResponseWriter, _ *http.Request) {
WriteProblem(w, http.StatusMethodNotAllowed, "common.method_not_allowed", "method not allowed")
}
// RequestError — ошибка разбора запроса (strict-server).
func RequestError(w http.ResponseWriter, _ *http.Request, err error) {
WriteProblem(w, http.StatusBadRequest, "common.bad_request", err.Error())
}
// ResponseError — непредвиденная ошибка обработчика: детали только в лог.
func ResponseError(log *slog.Logger) func(http.ResponseWriter, *http.Request, error) {
return func(w http.ResponseWriter, r *http.Request, err error) {
log.ErrorContext(r.Context(), "handler failed", "path", r.URL.Path, "err", err)
WriteProblem(w, http.StatusInternalServerError, "common.internal", "internal error")
}
}
+47
View File
@@ -0,0 +1,47 @@
// Package server собирает HTTP-роутер сервиса api из модулей.
package server
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/system"
)
// Deps — модули, реализующие части контракта API.
type Deps struct {
Log *slog.Logger
System *system.Handler
}
// handlers объединяет модули в единую реализацию gen.StrictServerInterface.
// По мере появления модулей (auth, catalog, ...) они встраиваются сюда.
type handlers struct {
*system.Handler
}
var _ gen.StrictServerInterface = handlers{}
// NewRouter возвращает корневой http.Handler.
func NewRouter(d Deps) http.Handler {
log := d.Log
if log == nil {
log = slog.New(slog.DiscardHandler)
}
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.RealIP, httpx.AccessLog(log), middleware.Recoverer)
r.NotFound(httpx.NotFound)
r.MethodNotAllowed(httpx.MethodNotAllowed)
strict := gen.NewStrictHandlerWithOptions(handlers{Handler: d.System}, nil, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: httpx.RequestError,
ResponseErrorHandlerFunc: httpx.ResponseError(log),
})
return gen.HandlerWithOptions(strict, gen.ChiServerOptions{BaseRouter: r})
}
@@ -0,0 +1,80 @@
// Package storageagent — внутренний сервис монтирования хранилищ (см. docs/library-and-storage.md).
// В M0 — каркас: проверка здоровья, аутентификация межсервисных запросов, список точек монтирования.
package storageagent
import (
"crypto/subtle"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"pipu/api/internal/platform/httpx"
)
// Mount — состояние точки монтирования источника.
type Mount struct {
SourceID string `json:"source_id"`
Path string `json:"path"`
}
// NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN.
func NewRouter(token, libraryRoot string) http.Handler {
r := chi.NewRouter()
r.NotFound(httpx.NotFound)
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Route("/internal", func(r chi.Router) {
r.Use(requireToken(token))
r.Get("/mounts", func(w http.ResponseWriter, _ *http.Request) {
mounts, err := listMounts(libraryRoot)
if err != nil {
httpx.WriteProblem(w, http.StatusInternalServerError, "storage.list_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts})
})
})
return r
}
func requireToken(token string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {
httpx.WriteProblem(w, http.StatusUnauthorized, "auth.invalid_internal_token", "invalid internal token")
return
}
next.ServeHTTP(w, r)
})
}
}
// listMounts перечисляет подкаталоги корня библиотеки (каждый — точка монтирования источника).
func listMounts(root string) ([]Mount, error) {
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return []Mount{}, nil
}
return nil, err
}
mounts := make([]Mount, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
mounts = append(mounts, Mount{SourceID: e.Name(), Path: filepath.Join(root, e.Name())})
}
}
return mounts, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
@@ -0,0 +1,49 @@
package storageagent
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInternalRequiresToken(t *testing.T) {
h := NewRouter("secret", t.TempDir())
for _, auth := range []string{"", "Bearer wrong", "secret"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil)
if auth != "" {
req.Header.Set("Authorization", auth)
}
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code, "auth=%q", auth)
}
}
func TestListMounts(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(root, "src-1"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, "stray.txt"), nil, 0o644))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil)
req.Header.Set("Authorization", "Bearer secret")
NewRouter("secret", root).ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var body struct{ Mounts []Mount }
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
assert.Equal(t, []Mount{{SourceID: "src-1", Path: filepath.Join(root, "src-1")}}, body.Mounts)
}
func TestListMountsMissingRoot(t *testing.T) {
mounts, err := listMounts(filepath.Join(t.TempDir(), "absent"))
require.NoError(t, err)
assert.Empty(t, mounts)
}
+77
View File
@@ -0,0 +1,77 @@
// Package system — служебные эндпоинты: живость, готовность, сведения о сервере.
package system
import (
"context"
"time"
"pipu/api/internal/api/gen"
)
// Checker проверяет одну зависимость (БД, Redis, ...). Возвращает ошибку, если она недоступна.
type Checker interface {
Check(ctx context.Context) error
}
// CheckerFunc адаптирует функцию к Checker.
type CheckerFunc func(ctx context.Context) error
// Check реализует Checker.
func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
// Info — статические сведения о сервере.
type Info struct {
Version string
SupportedLocales []string
MinAndroidVersionCode int
}
// Handler реализует системную часть gen.StrictServerInterface.
type Handler struct {
info Info
checks map[string]Checker
checkTimeout time.Duration
}
// NewHandler создаёт обработчик. checks — именованные проверки для /readyz.
func NewHandler(info Info, checks map[string]Checker) *Handler {
return &Handler{info: info, checks: checks, checkTimeout: 2 * time.Second}
}
// GetHealthz сообщает, что процесс жив; зависимости не проверяет.
func (h *Handler) GetHealthz(context.Context, gen.GetHealthzRequestObject) (gen.GetHealthzResponseObject, error) {
return gen.GetHealthz200JSONResponse{Status: gen.HealthStatusStatusOk}, nil
}
// GetReadyz проверяет все зависимости; 503, если хотя бы одна недоступна.
func (h *Handler) GetReadyz(ctx context.Context, _ gen.GetReadyzRequestObject) (gen.GetReadyzResponseObject, error) {
ctx, cancel := context.WithTimeout(ctx, h.checkTimeout)
defer cancel()
results := make(map[string]string, len(h.checks))
healthy := true
for name, c := range h.checks {
if err := c.Check(ctx); err != nil {
results[name] = err.Error()
healthy = false
continue
}
results[name] = "ok"
}
if !healthy {
return gen.GetReadyz503JSONResponse{Status: gen.ReadyStatusStatusDegraded, Checks: results}, nil
}
return gen.GetReadyz200JSONResponse{Status: gen.ReadyStatusStatusOk, Checks: results}, nil
}
// GetServerInfo отдаёт сведения о сервере для экрана подключения клиентов.
func (h *Handler) GetServerInfo(context.Context, gen.GetServerInfoRequestObject) (gen.GetServerInfoResponseObject, error) {
return gen.GetServerInfo200JSONResponse{
Name: "pipu-music",
Version: h.info.Version,
ApiVersion: "v1",
SupportedLocales: h.info.SupportedLocales,
MinAndroidVersionCode: h.info.MinAndroidVersionCode,
}, nil
}
@@ -0,0 +1,81 @@
package system_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pipu/api/internal/server"
"pipu/api/internal/system"
)
func newServer(checks map[string]system.Checker) http.Handler {
h := system.NewHandler(system.Info{
Version: "1.2.3",
SupportedLocales: []string{"ru", "en"},
MinAndroidVersionCode: 10203,
}, checks)
return server.NewRouter(server.Deps{System: h})
}
func get(t *testing.T, h http.Handler, path string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
var body map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), rec.Body.String())
return rec, body
}
func TestServerInfo(t *testing.T) {
rec, body := get(t, newServer(nil), "/api/v1/server-info")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "pipu-music", body["name"])
assert.Equal(t, "1.2.3", body["version"])
assert.Equal(t, "v1", body["api_version"])
assert.Equal(t, []any{"ru", "en"}, body["supported_locales"])
assert.EqualValues(t, 10203, body["min_android_version_code"])
}
func TestHealthz(t *testing.T) {
rec, body := get(t, newServer(nil), "/healthz")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "ok", body["status"])
}
func TestReadyz(t *testing.T) {
ok := system.CheckerFunc(func(context.Context) error { return nil })
down := system.CheckerFunc(func(context.Context) error { return errors.New("connection refused") })
t.Run("all dependencies up", func(t *testing.T) {
rec, body := get(t, newServer(map[string]system.Checker{"postgres": ok, "redis": ok}), "/readyz")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "ok", body["status"])
assert.Equal(t, map[string]any{"postgres": "ok", "redis": "ok"}, body["checks"])
})
t.Run("one dependency down", func(t *testing.T) {
rec, body := get(t, newServer(map[string]system.Checker{"postgres": ok, "redis": down}), "/readyz")
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
assert.Equal(t, "degraded", body["status"])
assert.Equal(t, map[string]any{"postgres": "ok", "redis": "connection refused"}, body["checks"])
})
}
func TestUnknownRouteIsProblemJSON(t *testing.T) {
rec, body := get(t, newServer(nil), "/api/v1/nope")
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Equal(t, "application/problem+json", rec.Header().Get("Content-Type"))
assert.Equal(t, "common.not_found", body["code"])
}
+13
View File
@@ -0,0 +1,13 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE app_settings (
key text PRIMARY KEY,
value jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE app_settings;
+9
View File
@@ -0,0 +1,9 @@
// Package migrations содержит SQL-миграции goose, встроенные в бинарник.
package migrations
import "embed"
// FS — файлы миграций.
//
//go:embed *.sql
var FS embed.FS
+1
View File
@@ -0,0 +1 @@
3.12
+26
View File
@@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1
# Сборка из корня репозитория:
# docker build -f services/recommender/Dockerfile --build-arg VERSION=1.2.3 .
# Один образ — две роли: `serve` (recommender) и `analyze --workers N` (analyzer).
FROM ghcr.io/astral-sh/uv:0.10.2 AS uv
FROM python:3.12-slim AS build
COPY --from=uv /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
WORKDIR /app
COPY services/recommender/pyproject.toml services/recommender/uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-install-project
COPY services/recommender/ ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
FROM python:3.12-slim
RUN useradd --system --uid 10001 pipu
COPY --from=build /app /app
ARG VERSION=0.0.0-dev
ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1
USER pipu
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["pipu-rec"]
CMD ["serve"]
@@ -0,0 +1,3 @@
"""Pipu Music: сервис рекомендаций и аудиоанализа (см. docs/recommendations.md)."""
__version__ = "0.0.0-dev"
Whitespace-only changes.
+48
View File
@@ -0,0 +1,48 @@
"""FastAPI-приложение внутреннего API рекомендаций."""
import hmac
from collections.abc import Awaitable, Callable
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, status
from fastapi.responses import JSONResponse
from pipu_rec.settings import Settings
Check = Callable[[], Awaitable[None]]
def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> FastAPI:
app = FastAPI(title="pipu-recommender", version=settings.version, docs_url=None, redoc_url=None)
checks = checks or {}
def require_token(authorization: Annotated[str, Header()] = "") -> None:
scheme, _, token = authorization.partition(" ")
expected = settings.internal_token
if scheme != "Bearer" or not expected or not hmac.compare_digest(token, expected):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="auth.invalid_internal_token")
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/readyz")
async def readyz() -> JSONResponse:
results: dict[str, str] = {}
for name, check in checks.items():
try:
await check()
results[name] = "ok"
except Exception as exc:
results[name] = str(exc) or type(exc).__name__
healthy = all(v == "ok" for v in results.values())
return JSONResponse(
{"status": "ok" if healthy else "degraded", "checks": results},
status_code=status.HTTP_200_OK if healthy else status.HTTP_503_SERVICE_UNAVAILABLE,
)
@app.get("/internal/status", dependencies=[Depends(require_token)])
async def internal_status() -> dict[str, object]:
return {"version": settings.version, "models": [], "ranker": "heuristic-v1"}
return app
+59
View File
@@ -0,0 +1,59 @@
"""Точка входа: `pipu-rec serve` (онлайн-API) или `pipu-rec analyze` (воркер аудиоанализа)."""
import argparse
import asyncio
import logging
import psycopg
import redis.asyncio as aioredis
import uvicorn
from pipu_rec.api.app import create_app
from pipu_rec.settings import Settings
log = logging.getLogger("pipu_rec")
def _serve(settings: Settings) -> None:
rdb = aioredis.Redis.from_url(settings.redis_url)
async def check_postgres() -> None:
async with await psycopg.AsyncConnection.connect(settings.database_url, connect_timeout=2) as conn:
await conn.execute("SELECT 1")
async def check_redis() -> None:
await rdb.ping()
app = create_app(settings, {"postgres": check_postgres, "redis": check_redis})
uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level)
def _analyze(settings: Settings, workers: int) -> None:
# Аудиоанализ (Essentia) появится в M4; сейчас воркер только держит процесс живым.
log.info("analyzer started: workers=%d version=%s (no-op until M4)", workers, settings.version)
async def idle() -> None:
while True:
await asyncio.sleep(3600)
asyncio.run(idle())
def main() -> None:
parser = argparse.ArgumentParser(prog="pipu-rec")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("serve", help="внутренний API рекомендаций")
analyze = sub.add_parser("analyze", help="воркер аудиоанализа")
analyze.add_argument("--workers", type=int, default=4)
args = parser.parse_args()
settings = Settings()
logging.basicConfig(level=settings.log_level.upper())
if args.cmd == "serve":
_serve(settings)
else:
_analyze(settings, args.workers)
if __name__ == "__main__":
main()
+16
View File
@@ -0,0 +1,16 @@
"""Конфигурация сервиса из переменных окружения."""
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="", extra="ignore")
http_host: str = "0.0.0.0"
http_port: int = 8000
database_url: str = Field(default="postgresql://pipu:pipu@postgres:5432/pipu")
redis_url: str = Field(default="redis://redis:6379/0")
internal_token: str = Field(default="", description="Общий секрет для вызовов из api")
log_level: str = "info"
version: str = Field(default="0.0.0-dev", alias="PIPU_VERSION")
+45
View File
@@ -0,0 +1,45 @@
[project]
name = "pipu-rec"
version = "0.1.0"
description = "Pipu Music: recommendations and audio analysis"
requires-python = ">=3.12,<3.13"
dependencies = [
"fastapi>=0.115,<1",
"uvicorn[standard]>=0.34,<1",
"pydantic-settings>=2.9,<3",
"psycopg[binary]>=3.2,<4",
"redis>=6.2,<7",
]
[project.scripts]
pipu-rec = "pipu_rec.cli:main"
[dependency-groups]
dev = [
"pytest>=8.4,<9",
"httpx>=0.28,<1",
"ruff>=0.12,<1",
"mypy>=1.16,<2",
]
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["pipu_rec"]
[tool.ruff]
line-length = 110
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
[tool.mypy]
strict = true
python_version = "3.12"
plugins = ["pydantic.mypy"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+45
View File
@@ -0,0 +1,45 @@
from fastapi.testclient import TestClient
from pipu_rec.api.app import create_app
from pipu_rec.settings import Settings
async def ok() -> None:
return None
async def down() -> None:
raise ConnectionError("connection refused")
def client(checks=None) -> TestClient: # type: ignore[no-untyped-def]
settings = Settings(internal_token="secret", PIPU_VERSION="1.2.3")
return TestClient(create_app(settings, checks))
def test_healthz() -> None:
r = client().get("/healthz")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_readyz_all_up() -> None:
r = client({"postgres": ok, "redis": ok}).get("/readyz")
assert r.status_code == 200
assert r.json() == {"status": "ok", "checks": {"postgres": "ok", "redis": "ok"}}
def test_readyz_dependency_down() -> None:
r = client({"postgres": ok, "redis": down}).get("/readyz")
assert r.status_code == 503
assert r.json()["checks"]["redis"] == "connection refused"
def test_internal_status_requires_token() -> None:
c = client()
assert c.get("/internal/status").status_code == 401
assert c.get("/internal/status", headers={"Authorization": "Bearer wrong"}).status_code == 401
r = c.get("/internal/status", headers={"Authorization": "Bearer secret"})
assert r.status_code == 200
assert r.json()["version"] == "1.2.3"
+606
View File
@@ -0,0 +1,606 @@
version = 1
revision = 3
requires-python = "==3.12.*"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "certifi"
version = "2026.10.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/eb/a2aa1ac3ea30dd3f30ca57f40232138b6b0040719316ecb85daf61167771/certifi-2026.10.18.tar.gz", hash = "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e", size = 135227, upload-time = "2026-10-18T16:21:07.655Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/ba/95dd616042484f7c83a06974388b37b837732740ee70bc5dc715b5fcd55a/certifi-2026.10.18-py3-none-any.whl", hash = "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", size = 134093, upload-time = "2026-10-18T16:21:06.276Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.149.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/37/6e7ec4dbe12d9721902066ddb83dd9c6e7adb1cee81e094502c43024e4d1/fastapi-0.149.0.tar.gz", hash = "sha256:aba5779358d0c5233759ddcddd84fe7269d71f775fd754b653b608f770558a41", size = 483369, upload-time = "2026-10-19T17:22:29.495Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/6c/78c324bd6725f69ebbac46e0d6a9a59744e108cececac4c1c8500c567512/fastapi-0.149.0-py3-none-any.whl", hash = "sha256:fc4a5cf7e14f607a48eb1ab98147e9140943e88ccd0c8791d72c0a89b1159a35", size = 145182, upload-time = "2026-10-19T17:22:27.753Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" },
{ url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" },
{ url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" },
{ url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" },
{ url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" },
{ url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" },
{ url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "librt"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" },
{ url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" },
{ url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" },
{ url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" },
{ url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" },
{ url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" },
{ url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" },
{ url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" },
{ url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" },
{ url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" },
{ url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" },
{ url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" },
{ url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" },
{ url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" },
]
[[package]]
name = "mypy"
version = "1.20.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pipu-rec"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
{ name = "redis" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" },
{ name = "pydantic-settings", specifier = ">=2.9,<3" },
{ name = "redis", specifier = ">=6.2,<7" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "mypy", specifier = ">=1.16,<2" },
{ name = "pytest", specifier = ">=8.4,<9" },
{ name = "ruff", specifier = ">=0.12,<1" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "psycopg"
version = "3.3.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.6"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" },
{ url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" },
{ url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" },
{ url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" },
{ url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" },
{ url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" },
{ url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" },
{ url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" },
{ url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" },
{ url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" },
]
[[package]]
name = "pydantic"
version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
]
[[package]]
name = "pydantic-core"
version = "2.50.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" },
{ url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" },
{ url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" },
{ url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" },
{ url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" },
{ url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" },
{ url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" },
{ url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" },
{ url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" },
{ url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" },
{ url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" },
{ url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" },
{ url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" },
{ url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" },
{ url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" },
{ url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/3b/a5d2294799b53b448319978cfb5bd139d5a9d45e862af91661614f14c922/pydantic_settings-2.16.0.tar.gz", hash = "sha256:5b6c578049ede4db0e2ef3b4eaa4ad4069cfa9211f83fb38df899dfade50a614", size = 301031, upload-time = "2026-10-14T12:44:09.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/f4/b987bf8c51e5b19a95fa66d1ee596074141e085d9c2ddf97920803c7029b/pydantic_settings-2.16.0-py3-none-any.whl", hash = "sha256:7e73acf7f61936a15e5a3b6eedaea29f133357faf7272f2607ba479b049dd7f2", size = 80364, upload-time = "2026-10-14T12:44:08.233Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
]
[[package]]
name = "redis"
version = "6.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
]
[[package]]
name = "ruff"
version = "0.17.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" },
{ url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" },
{ url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" },
{ url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" },
{ url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" },
{ url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" },
{ url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" },
{ url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" },
{ url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" },
{ url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" },
{ url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" },
{ url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" },
{ url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" },
{ url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" },
]
[[package]]
name = "starlette"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/eb/4e197bf26b023abafc5e9043ec8710d7de0fcb032d0969045236d72aa0b1/starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318", size = 2732466, upload-time = "2026-10-18T15:50:33.189Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "tzdata"
version = "2026.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
{ url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
{ url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
{ url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
{ url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
]
[[package]]
name = "watchfiles"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
]
[[package]]
name = "websockets"
version = "17.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/30/4b/4692be6d5bf739ed049b3813901c42091f8c054df89d5c089343823dea0d/websockets-17.3.tar.gz", hash = "sha256:1c7dd93a1c9f89297471493674b1fc1d9467cafd45374f2172213046bdb3a329", size = 188878, upload-time = "2026-10-19T16:58:05.813Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/29/902ffe19c0d35603102f31490dea342f083bc77d8f8bcdc7d8b16f5810f9/websockets-17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:41665c7ea01d3ae497a0c61fd22d3424230d18782a5fceb79f04db9ccb2042c9", size = 218250, upload-time = "2026-10-19T16:54:54.718Z" },
{ url = "https://files.pythonhosted.org/packages/f0/66/f56a2637b1aac263205b0e3cd7b2ff2e2f8b404b8118c991f4d122fd381f/websockets-17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8f7469408740cf06a79b01605f2ae0b2acc8839c03fcbbf05da49baee12d3302", size = 215927, upload-time = "2026-10-19T16:54:56.124Z" },
{ url = "https://files.pythonhosted.org/packages/22/7d/a27f25451c4cc62141992b51005688481210f3d3e63de3dc66f9e882ba59/websockets-17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d68da281ac208b6236ffa8beef23fe7c7c956d8bf09b421d447232fd94441b35", size = 216196, upload-time = "2026-10-19T16:54:57.538Z" },
{ url = "https://files.pythonhosted.org/packages/9f/77/40bbc1a5c016cf9f598dea21d1250a93245c995eafaec6401d0e4eb4e647/websockets-17.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078d1d41f45ee34eed5a4f3bbe7c588caecbdd6e12dfca73e8c1b0e803773793", size = 225516, upload-time = "2026-10-19T16:54:59.104Z" },
{ url = "https://files.pythonhosted.org/packages/3d/9c/91889c8c62fb7d16cd58e3074473a259303d6229bf0ce496aec87e66f3dc/websockets-17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7f391838e8adcf1f2fe71561d9e5a9f74ea7ffa5052eb2db329aac677383136", size = 225795, upload-time = "2026-10-19T16:55:00.438Z" },
{ url = "https://files.pythonhosted.org/packages/b8/29/2e5e2158e9920f806cd45f3cbc81cf5f3eef2b9e5a74b9ef049e2a3578e3/websockets-17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15a165673bb3fb57facbf040edf5c0556d20ccd2e0ccc5f23ebb69fb715fdc1", size = 227034, upload-time = "2026-10-19T16:55:02.065Z" },
{ url = "https://files.pythonhosted.org/packages/b6/af/fbab5c4098b26026b877bfc139b1ea0d7c6eaf7a86b84544933b4c445f82/websockets-17.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28d1935c16782a1e3650c9baa79069b016fff739e292e4cb14bbb830d6930491", size = 229863, upload-time = "2026-10-19T16:55:03.415Z" },
{ url = "https://files.pythonhosted.org/packages/ac/72/d6e460a991607794072fe3fdfcb3221a0342af61586f5bd7ea40bddcfd21/websockets-17.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c534c9524a2acd0b97b7022074bec3784ac273c683cf9535087e437672ea8531", size = 227669, upload-time = "2026-10-19T16:55:04.763Z" },
{ url = "https://files.pythonhosted.org/packages/ec/8f/1c7da1cfb632e141e019cf2e0bbbd4e6dc8c198c8cc29544e4acf1a89f56/websockets-17.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b459d29c8b82a420a98fa0ce8e9cf787ce0062d98cc90604ef9574fb3cc26ff8", size = 226429, upload-time = "2026-10-19T16:55:06.112Z" },
{ url = "https://files.pythonhosted.org/packages/3e/63/c48ef8bbfbd982f2cfae1cfcf9e63f5bfbb8468fd37ca700507efc0b0007/websockets-17.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53209dffd91b0aefed9ea630fa6ad5c2944d6210ba3a8a3a8664dc1575289b14", size = 223653, upload-time = "2026-10-19T16:55:07.473Z" },
{ url = "https://files.pythonhosted.org/packages/cb/37/7bedaad48ae8e89eaa4f3942c11be0c41de77ce2118a475f3ba05e9b5a0f/websockets-17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9662a9fe457820c60365b3209e03cbd8e4a817195d55de65a80fa680b4bea136", size = 226465, upload-time = "2026-10-19T16:55:08.791Z" },
{ url = "https://files.pythonhosted.org/packages/33/cf/ab9a8a0de4769ca1dce392021b6a922e21b4d3687fe9e1ab4db585a088f8/websockets-17.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5670a762c7ae388bf8aa04199fd436180a4d28318dca9c1eae7b38604676dbfd", size = 225071, upload-time = "2026-10-19T16:55:10.127Z" },
{ url = "https://files.pythonhosted.org/packages/89/96/64d8460cec95f873c61c6d13a09b435915150640525ae56d5871400aec09/websockets-17.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ed59cb4ee258674a6ed9b8515e42079db02cb3be94f133fdd3f200183a40c49d", size = 225841, upload-time = "2026-10-19T16:55:11.519Z" },
{ url = "https://files.pythonhosted.org/packages/a6/98/2b83211ee3087db4ae66533782664c0e283cf4a08cbb14036018d0803e09/websockets-17.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f78bb2e82fe88912c9d7b25c98e57a9b08655579af89f164fd4c0657a075d62", size = 226945, upload-time = "2026-10-19T16:55:12.85Z" },
{ url = "https://files.pythonhosted.org/packages/9f/fe/6b2a37d422d7f4dfc0c92c95a14649d9bd662243ed8b0774523ed2703604/websockets-17.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:71250e010eede33c1d638e2c7a98288520d67f782fd5eceecaeca3129233a09a", size = 224502, upload-time = "2026-10-19T16:55:14.48Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c5/364015c9311c89ea6085d54c70d654c5d27ba05014d44ef9fd9daa09462b/websockets-17.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:729989759feb81ff6493879a0d60a0bd7d2eee41001bbec0381a080318439382", size = 225483, upload-time = "2026-10-19T16:55:15.899Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b3/75d0cae8398816ed34a16a78252bfc8651f2f883cc5a568e1198395f15df/websockets-17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90d8c4fa5ba293cf92de351fbc292cdaadb1a1d58d031a1a97ff9b8fb6250ec7", size = 225761, upload-time = "2026-10-19T16:55:17.28Z" },
{ url = "https://files.pythonhosted.org/packages/f0/af/9a7c4b2e87f47a2a6a066acdc2157e9b7cb6222a224b95a432f54ae786ce/websockets-17.3-cp312-cp312-win32.whl", hash = "sha256:0824e6ecee838cc28e6481062574c7fde49f9a2cedfacd36f0e91cb4b37fd6fe", size = 218722, upload-time = "2026-10-19T16:55:18.658Z" },
{ url = "https://files.pythonhosted.org/packages/f7/e8/e52953a8280a1e51cb9307218443f68c8f4dc4b9952db6021d5c12d03c0f/websockets-17.3-cp312-cp312-win_amd64.whl", hash = "sha256:6644eecd99c4e8cdf72e646e17b391bb70b054f44becde37dcdc48cbec7e1527", size = 219024, upload-time = "2026-10-19T16:55:19.987Z" },
{ url = "https://files.pythonhosted.org/packages/85/a4/005eb7c1f5c1a249c1231a6ed83a54ada0b6b8ffab33a608ca20e3034ea0/websockets-17.3-cp312-cp312-win_arm64.whl", hash = "sha256:4a9133dbaedc6f1af358127275676171d9a1bb3e61f47ee62b2ff9acb78af3d0", size = 218950, upload-time = "2026-10-19T16:55:22.161Z" },
{ url = "https://files.pythonhosted.org/packages/5a/1a/5582103e0362f86c4725051ded89e585ceaf750f26beace530b7e42c81c0/websockets-17.3-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:0e65260a1b1d7b81651f47343d836de70b88b02358ae3658efa6382104ec37b1", size = 215872, upload-time = "2026-10-19T16:57:55.95Z" },
{ url = "https://files.pythonhosted.org/packages/ef/a7/f69d65ed3ff9ae73d428a918300c23a992e4d50683090b8dc33cc3341d5a/websockets-17.3-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:94c02b0702656f083c098bdbd93bce22a7aa7a2b34ba56d2472efb67dab7cf58", size = 216136, upload-time = "2026-10-19T16:57:57.251Z" },
{ url = "https://files.pythonhosted.org/packages/69/76/c64fda4ac18a3aeb32e66acec481011a766add480b6362fad80e2c3c1963/websockets-17.3-pp312-pypy312_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ade76f8c84ade325bbed96806357fffd353fcc29a5166d6dc6d39381f6d9f76c", size = 217080, upload-time = "2026-10-19T16:57:58.593Z" },
{ url = "https://files.pythonhosted.org/packages/6b/6d/085a3f0e6152bbe79917973f3d33cd008437de6c04675e2f15253b921f21/websockets-17.3-pp312-pypy312_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a215749c9a88ef2842e880215193e56ae60b856773c99702b078236bc6d4adb", size = 216966, upload-time = "2026-10-19T16:57:59.924Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b8/7eb904583ea2090b329f50487e6f8dc58eb558712b9f3c16f289f0be668e/websockets-17.3-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7b5d23ef144b32645d97bd99f382c4394c443395f45fe1576786838d26e308", size = 217828, upload-time = "2026-10-19T16:58:01.822Z" },
{ url = "https://files.pythonhosted.org/packages/79/36/f54b4a1cd9456ca0a65a76d77d181621433793aca633dfeed214748ff4f0/websockets-17.3-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:13eace1b315345ebac5b27821f5dcce24e5cf9976ab18f79e00a59bdd67e5d19", size = 306301, upload-time = "2026-10-19T16:58:03.161Z" },
{ url = "https://files.pythonhosted.org/packages/d7/b0/3f01fcb3406662f6deb1e3e72b34dd0979e993991c0b7435a9e12bc14f02/websockets-17.3-py3-none-any.whl", hash = "sha256:dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199", size = 212375, upload-time = "2026-10-19T16:58:04.483Z" },
]
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Детерминированно генерирует тестовую библиотеку (docs/testing.md#тестовая-музыка) в tests/fixtures/music/out.
# Реальная музыка в репозиторий не кладётся: звук — синусы и шум разного темпа, теги — вымышленные.
# Требуется ffmpeg. Запуск: tests/fixtures/music/generate.sh [out_dir]
set -euo pipefail
OUT="${1:-$(dirname "$0")/out}"
rm -rf "$OUT"
mkdir -p "$OUT"
# Метроном с заданным BPM: короткие щелчки на синусе — даёт стабильный темп для проверки анализа.
tone() { # tone <file> <freq> <bpm> <seconds> [ffmpeg metadata args...]
local file="$1" freq="$2" bpm="$3" secs="$4"; shift 4
mkdir -p "$(dirname "$file")"
ffmpeg -nostdin -loglevel error -y \
-f lavfi -i "sine=frequency=${freq}:duration=${secs}:sample_rate=44100" \
-f lavfi -i "sine=frequency=1800:duration=${secs}:sample_rate=44100" \
-filter_complex "[1]volume='if(lt(mod(t,60/${bpm}),0.03),1,0)':eval=frame[clk];[0][clk]amix=inputs=2:normalize=0[a]" \
-map "[a]" -ac 2 "$@" -map_metadata -1 -fflags +bitexact -flags:a +bitexact "$file"
}
meta() { # meta artist album year track title [genre]
local args=(-metadata "artist=$1" -metadata "album_artist=$1" -metadata "album=$2" -metadata "date=$3" -metadata "track=$4" -metadata "title=$5")
[ -n "${6:-}" ] && args+=(-metadata "genre=$6")
printf '%s\n' "${args[@]}"
}
with_meta() { # with_meta <file> <freq> <bpm> <secs> <artist> <album> <year> <track> <title> <genre|""> [encoder args...]
local file="$1" freq="$2" bpm="$3" secs="$4"; shift 4
mapfile -t m < <(meta "$1" "$2" "$3" "$4" "$5" "$6")
shift 6
tone "$file" "$freq" "$bpm" "$secs" "${m[@]}" "$@"
}
# 1. Структура «Исполнитель/Год - Альбом/NN - Название» — полные теги, разные форматы.
with_meta "$OUT/Тестовый Ансамбль/2019 - Первый альбом/01 - Утро.flac" 440 90 8 "Тестовый Ансамбль" "Первый альбом" 2019 1 "Утро" "Rock"
with_meta "$OUT/Тестовый Ансамбль/2019 - Первый альбом/02 - День.flac" 494 128 8 "Тестовый Ансамбль" "Первый альбом" 2019 2 "День" "Rock"
with_meta "$OUT/Тестовый Ансамбль/2019 - Первый альбом/03 - Вечер.flac" 523 170 8 "Тестовый Ансамбль" "Первый альбом" 2019 3 "Вечер" "Rock"
with_meta "$OUT/Test Collective/2021 - Second Album/01 - Signal.mp3" 330 120 6 "Test Collective" "Second Album" 2021 1 "Signal" "Electronic" -b:a 192k
with_meta "$OUT/Test Collective/2021 - Second Album/02 - Noise.opus" 349 140 6 "Test Collective" "Second Album" 2021 2 "Noise" "Electronic" -b:a 96k
with_meta "$OUT/Test Collective/2021 - Second Album/03 - Drift.m4a" 392 100 6 "Test Collective" "Second Album" 2021 3 "Drift" "Electronic" -c:a aac -b:a 160k
# 2. Многодисковый альбом: «1-01 - …».
with_meta "$OUT/Тестовый Ансамбль/2020 - Двойной/1-01 - Сторона А.mp3" 587 110 5 "Тестовый Ансамбль" "Двойной" 2020 1 "Сторона А" "" -b:a 128k
with_meta "$OUT/Тестовый Ансамбль/2020 - Двойной/2-01 - Сторона Б.mp3" 622 115 5 "Тестовый Ансамбль" "Двойной" 2020 1 "Сторона Б" "" -b:a 128k
# 3. Без тегов — данные только из пути (проверка регэкспов).
tone "$OUT/Безымянный/2018 - Только путь/01 - Без тегов.flac" 660 95 5
tone "$OUT/Безымянный/2018 - Только путь/02 - Тоже без тегов.mp3" 698 105 5 -b:a 128k
# 4. Другая структура: «Жанр/Исполнитель/Альбом (Год)/NN. Название».
with_meta "$OUT/Jazz/Quiet Trio/Night Set (2015)/01. Blue.flac" 262 75 6 "Quiet Trio" "Night Set" 2015 1 "Blue" "Jazz"
# 5. Сборник: «Compilations/Альбом/NN - Исполнитель - Название».
with_meta "$OUT/Compilations/Best Of Tests/01 - Test Collective - Signal (Edit).mp3" 330 120 4 "Test Collective" "Best Of Tests" 2022 1 "Signal (Edit)" "" -b:a 192k
# 6. Дубли: та же запись в другом формате и точная копия файла (проверка дедупликации).
with_meta "$OUT/_dupes/День (mp3).mp3" 494 128 8 "Тестовый Ансамбль" "Первый альбом" 2019 2 "День" "Rock" -b:a 192k
mkdir -p "$OUT/_dupes/copy"
cp "$OUT/Тестовый Ансамбль/2019 - Первый альбом/01 - Утро.flac" "$OUT/_dupes/copy/01 - Утро.flac"
# 7. Мусор, который сканер должен пропустить или пометить ошибкой.
printf 'not audio' > "$OUT/Безымянный/2018 - Только путь/broken.mp3"
printf 'cover' > "$OUT/Тестовый Ансамбль/2019 - Первый альбом/notes.txt"
echo "generated $(find "$OUT" -type f | wc -l) files in $OUT"
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"forceConsistentCasingInFileNames": true
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"generate": {
"inputs": ["$TURBO_ROOT$/api/openapi.yaml", "tokens.json", "scripts/**"],
"outputs": ["src/generated/**"]
},
"build": {
"dependsOn": ["^build", "generate"],
"outputs": ["dist/**"]
},
"typecheck": {
"dependsOn": ["^build", "generate"]
},
"lint": {},
"test": {
"dependsOn": ["^build", "generate"],
"outputs": ["coverage/**"]
},
"check": {}
}
}